From 19ba8bcddf6e55075c3a91b26d9feb98aa1582f5 Mon Sep 17 00:00:00 2001 From: caadiq Date: Fri, 16 Jan 2026 21:11:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20Express=EC=97=90=EC=84=9C=20Fastify?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Express → Fastify 5 프레임워크 전환 - 플러그인 기반 아키텍처로 재구성 - plugins/db.js: MariaDB 연결 풀 - plugins/redis.js: Redis 클라이언트 - plugins/scheduler.js: 봇 스케줄러 (node-cron) - 봇 설정 방식 변경: DB 테이블 → 설정 파일 (config/bots.js) - 봇 상태 저장: DB → Redis - YouTube/X 봇 서비스 분리 및 개선 - 날짜 유틸리티 KST 변환 수정 - 미사용 환경변수 정리 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + Dockerfile | 2 +- backend/lib/date.js | 85 - backend/lib/db.js | 15 - backend/lib/redis.js | 19 - backend/package-lock.json | 4734 +++++-------------------- backend/package.json | 40 +- backend/routes/admin.js | 2147 ----------- backend/routes/albums.js | 180 - backend/routes/members.js | 35 - backend/routes/schedules.js | 292 -- backend/routes/stats.js | 28 - backend/server.js | 81 - backend/services/meilisearch-bot.js | 84 - backend/services/meilisearch.js | 227 -- backend/services/suggestions.js | 248 -- backend/services/x-bot.js | 687 ---- backend/services/youtube-bot.js | 648 ---- backend/services/youtube-scheduler.js | 201 -- backend/src/app.js | 47 + backend/src/config/bots.js | 37 + backend/src/config/index.js | 22 + backend/src/plugins/db.js | 25 + backend/src/plugins/redis.js | 27 + backend/src/plugins/scheduler.js | 170 + backend/src/server.js | 24 + backend/src/services/x/index.js | 198 ++ backend/src/services/x/scraper.js | 195 + backend/src/services/youtube/api.js | 181 + backend/src/services/youtube/index.js | 141 + backend/src/utils/date.js | 40 + docker-compose.dev.yml | 4 +- docker-compose.yml | 7 + frontend/.env | 1 - 34 files changed, 2015 insertions(+), 8860 deletions(-) delete mode 100644 backend/lib/date.js delete mode 100644 backend/lib/db.js delete mode 100644 backend/lib/redis.js delete mode 100644 backend/routes/admin.js delete mode 100644 backend/routes/albums.js delete mode 100644 backend/routes/members.js delete mode 100644 backend/routes/schedules.js delete mode 100644 backend/routes/stats.js delete mode 100644 backend/server.js delete mode 100644 backend/services/meilisearch-bot.js delete mode 100644 backend/services/meilisearch.js delete mode 100644 backend/services/suggestions.js delete mode 100644 backend/services/x-bot.js delete mode 100644 backend/services/youtube-bot.js delete mode 100644 backend/services/youtube-scheduler.js create mode 100644 backend/src/app.js create mode 100644 backend/src/config/bots.js create mode 100644 backend/src/config/index.js create mode 100644 backend/src/plugins/db.js create mode 100644 backend/src/plugins/redis.js create mode 100644 backend/src/plugins/scheduler.js create mode 100644 backend/src/server.js create mode 100644 backend/src/services/x/index.js create mode 100644 backend/src/services/x/scraper.js create mode 100644 backend/src/services/youtube/api.js create mode 100644 backend/src/services/youtube/index.js create mode 100644 backend/src/utils/date.js diff --git a/.gitignore b/.gitignore index b5fd2b3..3e6fd5b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ redis_data/ backend/scrape_*.cjs backend/scrape_*.js backend/scrape_*.txt + +# Backup +backend-backup/ diff --git a/Dockerfile b/Dockerfile index fb9c2b8..fd54062 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ COPY backend/ ./ COPY --from=frontend-builder /frontend/dist ./dist EXPOSE 80 -CMD ["node", "server.js"] +CMD ["npm", "start"] diff --git a/backend/lib/date.js b/backend/lib/date.js deleted file mode 100644 index efb0225..0000000 --- a/backend/lib/date.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 날짜/시간 유틸리티 (dayjs 기반) - * 백엔드 전체에서 공통으로 사용 - */ - -import dayjs from "dayjs"; -import utc from "dayjs/plugin/utc.js"; -import timezone from "dayjs/plugin/timezone.js"; -import customParseFormat from "dayjs/plugin/customParseFormat.js"; - -// 플러그인 등록 -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(customParseFormat); - -// 기본 시간대: KST -const KST = "Asia/Seoul"; - -/** - * UTC 시간을 KST로 변환 - * @param {Date|string} utcDate - UTC 시간 - * @returns {dayjs.Dayjs} KST 시간 - */ -export function toKST(utcDate) { - return dayjs(utcDate).tz(KST); -} - -/** - * 날짜를 YYYY-MM-DD 형식으로 포맷 - * @param {Date|string|dayjs.Dayjs} date - 날짜 - * @returns {string} YYYY-MM-DD - */ -export function formatDate(date) { - return dayjs(date).format("YYYY-MM-DD"); -} - -/** - * 시간을 HH:mm:ss 형식으로 포맷 - * @param {Date|string|dayjs.Dayjs} date - 시간 - * @returns {string} HH:mm:ss - */ -export function formatTime(date) { - return dayjs(date).format("HH:mm:ss"); -} - -/** - * UTC 시간을 KST로 변환 후 날짜/시간 분리 - * @param {Date|string} utcDate - UTC 시간 - * @returns {{date: string, time: string}} KST 날짜/시간 - */ -export function utcToKSTDateTime(utcDate) { - const kst = toKST(utcDate); - return { - date: kst.format("YYYY-MM-DD"), - time: kst.format("HH:mm:ss"), - }; -} - -/** - * 현재 KST 시간 반환 - * @returns {dayjs.Dayjs} 현재 KST 시간 - */ -export function nowKST() { - return dayjs().tz(KST); -} - -/** - * Nitter 날짜 문자열 파싱 (UTC 반환) - * 예: "Jan 9, 2026 · 4:00 PM UTC" → Date 객체 - * @param {string} timeStr - Nitter 날짜 문자열 - * @returns {dayjs.Dayjs|null} UTC 시간 - */ -export function parseNitterDateTime(timeStr) { - if (!timeStr) return null; - try { - const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); - const date = dayjs.utc(cleaned, "MMM D, YYYY h:mm A"); - if (!date.isValid()) return null; - return date; - } catch (e) { - return null; - } -} - -export default dayjs; diff --git a/backend/lib/db.js b/backend/lib/db.js deleted file mode 100644 index 0749f70..0000000 --- a/backend/lib/db.js +++ /dev/null @@ -1,15 +0,0 @@ -import mysql from "mysql2/promise"; - -// MariaDB 연결 풀 생성 -const pool = mysql.createPool({ - host: process.env.DB_HOST || "mariadb", - port: parseInt(process.env.DB_PORT) || 3306, - user: process.env.DB_USER || "fromis9", - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME || "fromis9", - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, -}); - -export default pool; diff --git a/backend/lib/redis.js b/backend/lib/redis.js deleted file mode 100644 index 195f4c7..0000000 --- a/backend/lib/redis.js +++ /dev/null @@ -1,19 +0,0 @@ -import Redis from "ioredis"; - -// Redis 클라이언트 초기화 -const redis = new Redis({ - host: "fromis9-redis", - port: 6379, - retryDelayOnFailover: 100, - maxRetriesPerRequest: 3, -}); - -redis.on("connect", () => { - console.log("[Redis] 연결 성공"); -}); - -redis.on("error", (err) => { - console.error("[Redis] 연결 오류:", err.message); -}); - -export default redis; diff --git a/backend/package-lock.json b/backend/package-lock.json index 8c7d1fb..32f17f7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,3859 +1,883 @@ { - "name": "fromis9-backend", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fromis9-backend", - "version": "1.0.0", - "dependencies": { - "@aws-sdk/client-s3": "^3.700.0", - "bcrypt": "^6.0.0", - "dayjs": "^1.11.19", - "express": "^4.18.2", - "fluent-ffmpeg": "^2.1.3", - "inko": "^1.1.1", - "ioredis": "^5.4.0", - "jsonwebtoken": "^9.0.3", - "meilisearch": "^0.55.0", - "multer": "^1.4.5-lts.1", - "mysql2": "^3.11.0", - "node-cron": "^4.2.1", - "rss-parser": "^3.13.0", - "sharp": "^0.33.5" - } - }, - "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.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.958.0.tgz", - "integrity": "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg==", - "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.957.0", - "@aws-sdk/credential-provider-node": "3.958.0", - "@aws-sdk/middleware-bucket-endpoint": "3.957.0", - "@aws-sdk/middleware-expect-continue": "3.957.0", - "@aws-sdk/middleware-flexible-checksums": "3.957.0", - "@aws-sdk/middleware-host-header": "3.957.0", - "@aws-sdk/middleware-location-constraint": "3.957.0", - "@aws-sdk/middleware-logger": "3.957.0", - "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-sdk-s3": "3.957.0", - "@aws-sdk/middleware-ssec": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", - "@aws-sdk/region-config-resolver": "3.957.0", - "@aws-sdk/signature-v4-multi-region": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-endpoints": "3.957.0", - "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", - "@smithy/config-resolver": "^4.4.5", - "@smithy/core": "^3.20.0", - "@smithy/eventstream-serde-browser": "^4.2.7", - "@smithy/eventstream-serde-config-resolver": "^4.3.7", - "@smithy/eventstream-serde-node": "^4.2.7", - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/hash-blob-browser": "^4.2.8", - "@smithy/hash-node": "^4.2.7", - "@smithy/hash-stream-node": "^4.2.7", - "@smithy/invalid-dependency": "^4.2.7", - "@smithy/md5-js": "^4.2.7", - "@smithy/middleware-content-length": "^4.2.7", - "@smithy/middleware-endpoint": "^4.4.1", - "@smithy/middleware-retry": "^4.4.17", - "@smithy/middleware-serde": "^4.2.8", - "@smithy/middleware-stack": "^4.2.7", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.16", - "@smithy/util-defaults-mode-node": "^4.2.19", - "@smithy/util-endpoints": "^3.2.7", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-retry": "^4.2.7", - "@smithy/util-stream": "^4.5.8", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", - "integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.957.0", - "@aws-sdk/middleware-host-header": "3.957.0", - "@aws-sdk/middleware-logger": "3.957.0", - "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", - "@aws-sdk/region-config-resolver": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-endpoints": "3.957.0", - "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", - "@smithy/config-resolver": "^4.4.5", - "@smithy/core": "^3.20.0", - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/hash-node": "^4.2.7", - "@smithy/invalid-dependency": "^4.2.7", - "@smithy/middleware-content-length": "^4.2.7", - "@smithy/middleware-endpoint": "^4.4.1", - "@smithy/middleware-retry": "^4.4.17", - "@smithy/middleware-serde": "^4.2.8", - "@smithy/middleware-stack": "^4.2.7", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.16", - "@smithy/util-defaults-mode-node": "^4.2.19", - "@smithy/util-endpoints": "^3.2.7", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-retry": "^4.2.7", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz", - "integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@aws-sdk/xml-builder": "3.957.0", - "@smithy/core": "^3.20.0", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/signature-v4": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/crc64-nvme": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.957.0.tgz", - "integrity": "sha512-qSwSfI+qBU9HDsd6/4fM9faCxYJx2yDuHtj+NVOQ6XYDWQzFab/hUdwuKZ77Pi6goLF1pBZhJ2azaC2w7LbnTA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz", - "integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz", - "integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/util-stream": "^4.5.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.958.0.tgz", - "integrity": "sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/credential-provider-env": "3.957.0", - "@aws-sdk/credential-provider-http": "3.957.0", - "@aws-sdk/credential-provider-login": "3.958.0", - "@aws-sdk/credential-provider-process": "3.957.0", - "@aws-sdk/credential-provider-sso": "3.958.0", - "@aws-sdk/credential-provider-web-identity": "3.958.0", - "@aws-sdk/nested-clients": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/credential-provider-imds": "^4.2.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.958.0.tgz", - "integrity": "sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.958.0.tgz", - "integrity": "sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.957.0", - "@aws-sdk/credential-provider-http": "3.957.0", - "@aws-sdk/credential-provider-ini": "3.958.0", - "@aws-sdk/credential-provider-process": "3.957.0", - "@aws-sdk/credential-provider-sso": "3.958.0", - "@aws-sdk/credential-provider-web-identity": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/credential-provider-imds": "^4.2.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz", - "integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz", - "integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.958.0", - "@aws-sdk/core": "3.957.0", - "@aws-sdk/token-providers": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz", - "integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.957.0.tgz", - "integrity": "sha512-iczcn/QRIBSpvsdAS/rbzmoBpleX1JBjXvCynMbDceVLBIcVrwT1hXECrhtIC2cjh4HaLo9ClAbiOiWuqt+6MA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-arn-parser": "3.957.0", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-config-provider": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.957.0.tgz", - "integrity": "sha512-AlbK3OeVNwZZil0wlClgeI/ISlOt/SPUxBsIns876IFaVu/Pj3DgImnYhpcJuFRek4r4XM51xzIaGQXM6GDHGg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz", - "integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==", - "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.957.0", - "@aws-sdk/crc64-nvme": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-stream": "^4.5.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.957.0.tgz", - "integrity": "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.957.0.tgz", - "integrity": "sha512-y8/W7TOQpmDJg/fPYlqAhwA4+I15LrS7TwgUEoxogtkD8gfur9wFMRLT8LCyc9o4NMEcAnK50hSb4+wB0qv6tQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-logger": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.957.0.tgz", - "integrity": "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.957.0.tgz", - "integrity": "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz", - "integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-arn-parser": "3.957.0", - "@smithy/core": "^3.20.0", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/signature-v4": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-stream": "^4.5.8", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.957.0.tgz", - "integrity": "sha512-qwkmrK0lizdjNt5qxl4tHYfASh8DFpHXM1iDVo+qHe+zuslfMqQEGRkzxS8tJq/I+8F0c6v3IKOveKJAfIvfqQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz", - "integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-endpoints": "3.957.0", - "@smithy/core": "^3.20.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz", - "integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.957.0", - "@aws-sdk/middleware-host-header": "3.957.0", - "@aws-sdk/middleware-logger": "3.957.0", - "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", - "@aws-sdk/region-config-resolver": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@aws-sdk/util-endpoints": "3.957.0", - "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", - "@smithy/config-resolver": "^4.4.5", - "@smithy/core": "^3.20.0", - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/hash-node": "^4.2.7", - "@smithy/invalid-dependency": "^4.2.7", - "@smithy/middleware-content-length": "^4.2.7", - "@smithy/middleware-endpoint": "^4.4.1", - "@smithy/middleware-retry": "^4.4.17", - "@smithy/middleware-serde": "^4.2.8", - "@smithy/middleware-stack": "^4.2.7", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.16", - "@smithy/util-defaults-mode-node": "^4.2.19", - "@smithy/util-endpoints": "^3.2.7", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-retry": "^4.2.7", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.957.0.tgz", - "integrity": "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/config-resolver": "^4.4.5", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz", - "integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/signature-v4": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz", - "integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", - "@aws-sdk/types": "3.957.0", - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.957.0.tgz", - "integrity": "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.957.0.tgz", - "integrity": "sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-endpoints": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.957.0.tgz", - "integrity": "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-endpoints": "^3.2.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.957.0.tgz", - "integrity": "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.957.0.tgz", - "integrity": "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.957.0", - "@smithy/types": "^4.11.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz", - "integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.957.0", - "@aws-sdk/types": "3.957.0", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", - "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.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.957.0.tgz", - "integrity": "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@ioredis/commands": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", - "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", - "license": "MIT" - }, - "node_modules/@smithy/abort-controller": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", - "integrity": "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", - "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", - "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/config-resolver": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.5.tgz", - "integrity": "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.7", - "@smithy/util-middleware": "^4.2.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.0.tgz", - "integrity": "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/middleware-serde": "^4.2.8", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-stream": "^4.5.8", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.7.tgz", - "integrity": "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.7.tgz", - "integrity": "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.11.0", - "@smithy/util-hex-encoding": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.7.tgz", - "integrity": "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.7.tgz", - "integrity": "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.7.tgz", - "integrity": "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.7.tgz", - "integrity": "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/eventstream-codec": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", - "integrity": "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/querystring-builder": "^4.2.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.8.tgz", - "integrity": "sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.0", - "@smithy/chunked-blob-reader-native": "^4.2.1", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.7.tgz", - "integrity": "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.7.tgz", - "integrity": "sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/invalid-dependency": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.7.tgz", - "integrity": "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/md5-js": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.7.tgz", - "integrity": "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.7.tgz", - "integrity": "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.1.tgz", - "integrity": "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.20.0", - "@smithy/middleware-serde": "^4.2.8", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-middleware": "^4.2.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-retry": { - "version": "4.4.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.17.tgz", - "integrity": "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/service-error-classification": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-retry": "^4.2.7", - "@smithy/uuid": "^1.1.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.8.tgz", - "integrity": "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/middleware-stack": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.7.tgz", - "integrity": "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-config-provider": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz", - "integrity": "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.7.tgz", - "integrity": "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/querystring-builder": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/property-provider": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.7.tgz", - "integrity": "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/protocol-http": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.7.tgz", - "integrity": "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.7.tgz", - "integrity": "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/querystring-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.7.tgz", - "integrity": "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/service-error-classification": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.7.tgz", - "integrity": "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz", - "integrity": "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.7.tgz", - "integrity": "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/smithy-client": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.2.tgz", - "integrity": "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.20.0", - "@smithy/middleware-endpoint": "^4.4.1", - "@smithy/middleware-stack": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-stream": "^4.5.8", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.11.0.tgz", - "integrity": "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/url-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.7.tgz", - "integrity": "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/querystring-parser": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.16", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.16.tgz", - "integrity": "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/property-provider": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.19", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.19.tgz", - "integrity": "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/config-resolver": "^4.4.5", - "@smithy/credential-provider-imds": "^4.2.7", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-endpoints": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz", - "integrity": "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-middleware": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.7.tgz", - "integrity": "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-retry": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.7.tgz", - "integrity": "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/service-error-classification": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-stream": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.8.tgz", - "integrity": "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-waiter": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.7.tgz", - "integrity": "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/abort-controller": "^4.2.7", - "@smithy/types": "^4.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/bcrypt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", - "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bowser": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", - "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "license": "ISC" - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/commander": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", - "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "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/diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fluent-ffmpeg": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", - "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "dependencies": { - "async": "^0.2.9", - "which": "^1.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", - "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", - "license": "MIT", - "engines": { - "node": ">=4.x" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", - "integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inko": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/inko/-/inko-1.1.1.tgz", - "integrity": "sha512-Lr+HH4xr1eT0OKokYXjr+lRk6ubVEw6iCpOEGsdeokwLsvLXODWZG2XuzvTOlCl5hEVApK8TVWSkWnf55c6RJA==", - "license": "MIT", - "dependencies": { - "mocha": "^5.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/ioredis": { - "version": "5.9.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.1.tgz", - "integrity": "sha512-BXNqFQ66oOsR82g9ajFFsR8ZKrjVvYCLyeML9IvSMAsP56XH2VXBdZjmI11p65nXXJxTEt1hie3J2QeFJVgrtQ==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.5.0", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, - "node_modules/ioredis/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/ioredis/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/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT" - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jsonwebtoken": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", - "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", - "license": "MIT", - "dependencies": { - "jws": "^4.0.1", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jsonwebtoken/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/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru.min": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", - "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/meilisearch": { - "version": "0.55.0", - "resolved": "https://registry.npmjs.org/meilisearch/-/meilisearch-0.55.0.tgz", - "integrity": "sha512-qSMeiezfDgIqciIeYzh5E4pXDZZD7CtHeWDCs43kN3trLgl5FtfmBAIkljL3huFaOx08feYtC8FfIFUpVwq6rg==", - "license": "MIT" - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", - "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "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": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mocha": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", - "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", - "license": "MIT", - "dependencies": { - "browser-stdout": "1.3.1", - "commander": "2.15.1", - "debug": "3.1.0", - "diff": "3.5.0", - "escape-string-regexp": "1.0.5", - "glob": "7.1.2", - "growl": "1.10.5", - "he": "1.1.1", - "minimatch": "3.0.4", - "mkdirp": "0.5.1", - "supports-color": "5.4.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/mocha/node_modules/debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/mocha/node_modules/minimist": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", - "license": "MIT" - }, - "node_modules/mocha/node_modules/mkdirp": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", - "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", - "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", - "license": "MIT", - "dependencies": { - "minimist": "0.0.8" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mysql2": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz", - "integrity": "sha512-AEGW7QLLSuSnjCS4pk3EIqOmogegmze9h8EyrndavUQnIUcfkVal/sK7QznE+a3bc6rzPbAiui9Jcb+96tPwYA==", - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" - }, - "engines": { - "node": ">= 8.0" - } - }, - "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", - "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, - "node_modules/node-cron": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", - "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "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", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", - "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/rss-parser": { - "version": "3.13.0", - "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", - "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", - "license": "MIT", - "dependencies": { - "entities": "^2.0.3", - "xml2js": "^0.5.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "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/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/sax": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", - "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", - "license": "BlueOak-1.0.0" - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/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/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", - "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "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/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "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", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "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" - }, - "node_modules/xml2js": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", - "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } + "name": "fromis9-backend", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fromis9-backend", + "version": "2.0.0", + "dependencies": { + "dayjs": "^1.11.13", + "fastify": "^5.2.1", + "fastify-plugin": "^5.0.1", + "ioredis": "^5.4.2", + "mysql2": "^3.12.0", + "node-cron": "^3.0.3" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.1.0.tgz", + "integrity": "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.2.0.tgz", + "integrity": "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.1.tgz", + "integrity": "sha512-ZW7S4fxlZhE+tYWVokFzjh+i56R+buYKNGhrVl6DtN8sxkyMEzpJnzvO8A/ZZrsg5w6X37u6I4EOQikYS5DXpA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", + "integrity": "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.3.tgz", + "integrity": "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "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/mysql2": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.1.tgz", + "integrity": "sha512-b75qsDB3ieYEzMsT1uRGsztM/sy6vWPY40uPZlVVl8eefAotFCoS7jaDB5DxDNtlW5kdVGd9jptSpkvujNxI2A==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.6", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "license": "ISC", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.2.0.tgz", + "integrity": "sha512-NFnZqUliT+OHkRXVSf8vdOr13N1wv31hRryVjqbreVh/SDCNaI6mnRDDq89HVRCbem1SAl7yj04OANeqP0nT6A==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } } + } } diff --git a/backend/package.json b/backend/package.json index 643e866..fce3e25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,25 +1,17 @@ { - "name": "fromis9-backend", - "private": true, - "version": "1.0.0", - "type": "module", - "scripts": { - "start": "node server.js" - }, - "dependencies": { - "@aws-sdk/client-s3": "^3.700.0", - "bcrypt": "^6.0.0", - "dayjs": "^1.11.19", - "express": "^4.18.2", - "inko": "^1.1.1", - "ioredis": "^5.4.0", - "jsonwebtoken": "^9.0.3", - "meilisearch": "^0.55.0", - "multer": "^1.4.5-lts.1", - "mysql2": "^3.11.0", - "node-cron": "^4.2.1", - "rss-parser": "^3.13.0", - "sharp": "^0.33.5", - "fluent-ffmpeg": "^2.1.3" - } -} \ No newline at end of file + "name": "fromis9-backend", + "version": "2.0.0", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "fastify": "^5.2.1", + "fastify-plugin": "^5.0.1", + "mysql2": "^3.12.0", + "ioredis": "^5.4.2", + "node-cron": "^3.0.3", + "dayjs": "^1.11.13" + } +} diff --git a/backend/routes/admin.js b/backend/routes/admin.js deleted file mode 100644 index d3303aa..0000000 --- a/backend/routes/admin.js +++ /dev/null @@ -1,2147 +0,0 @@ -import express from "express"; -import bcrypt from "bcrypt"; -import jwt from "jsonwebtoken"; -import multer from "multer"; -import sharp from "sharp"; -import ffmpeg from "fluent-ffmpeg"; -import fs from "fs/promises"; -import os from "os"; -import path from "path"; -import { - S3Client, - PutObjectCommand, - DeleteObjectCommand, -} from "@aws-sdk/client-s3"; -import pool from "../lib/db.js"; -import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; -import { syncAllTweets } from "../services/x-bot.js"; -import { syncAllSchedules } from "../services/meilisearch-bot.js"; -import { startBot, stopBot } from "../services/youtube-scheduler.js"; -import { - addOrUpdateSchedule, - deleteSchedule as deleteScheduleFromSearch, -} from "../services/meilisearch.js"; - -const router = express.Router(); - -// JWT 설정 -const JWT_SECRET = process.env.JWT_SECRET || "fromis9-admin-secret-key-2026"; -const JWT_EXPIRES_IN = "30d"; - -// Multer 설정 (메모리 저장) -const upload = multer({ - storage: multer.memoryStorage(), - limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (동영상 지원) - fileFilter: (req, file, cb) => { - // 이미지 또는 MP4 비디오 허용 - if (file.mimetype.startsWith("image/") || file.mimetype === "video/mp4") { - cb(null, true); - } else { - cb(new Error("이미지 또는 MP4 파일만 업로드 가능합니다."), false); - } - }, -}); - -// S3 클라이언트 (RustFS) -const s3Client = new S3Client({ - endpoint: process.env.RUSTFS_ENDPOINT, - region: "us-east-1", - credentials: { - accessKeyId: process.env.RUSTFS_ACCESS_KEY, - secretAccessKey: process.env.RUSTFS_SECRET_KEY, - }, - forcePathStyle: true, -}); - -const BUCKET = process.env.RUSTFS_BUCKET || "fromis-9"; - -// 토큰 검증 미들웨어 -export const authenticateToken = (req, res, next) => { - const authHeader = req.headers["authorization"]; - const token = authHeader && authHeader.split(" ")[1]; - - if (!token) { - return res.status(401).json({ error: "인증이 필요합니다." }); - } - - jwt.verify(token, JWT_SECRET, (err, user) => { - if (err) { - return res.status(403).json({ error: "유효하지 않은 토큰입니다." }); - } - req.user = user; - next(); - }); -}; - -// 관리자 로그인 -router.post("/login", async (req, res) => { - try { - const { username, password } = req.body; - - if (!username || !password) { - return res - .status(400) - .json({ error: "아이디와 비밀번호를 입력해주세요." }); - } - - const [users] = await pool.query( - "SELECT * FROM admin_users WHERE username = ?", - [username] - ); - - if (users.length === 0) { - return res - .status(401) - .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); - } - - const user = users[0]; - const isValidPassword = await bcrypt.compare(password, user.password_hash); - - if (!isValidPassword) { - return res - .status(401) - .json({ error: "아이디 또는 비밀번호가 올바르지 않습니다." }); - } - - const token = jwt.sign( - { id: user.id, username: user.username }, - JWT_SECRET, - { expiresIn: JWT_EXPIRES_IN } - ); - - res.json({ - message: "로그인 성공", - token, - user: { id: user.id, username: user.username }, - }); - } catch (error) { - console.error("로그인 오류:", error); - res.status(500).json({ error: "로그인 처리 중 오류가 발생했습니다." }); - } -}); - -// 토큰 검증 엔드포인트 -router.get("/verify", authenticateToken, (req, res) => { - res.json({ valid: true, user: req.user }); -}); - -// 초기 관리자 계정 생성 -router.post("/init", async (req, res) => { - try { - const [existing] = await pool.query( - "SELECT COUNT(*) as count FROM admin_users" - ); - - if (existing[0].count > 0) { - return res.status(400).json({ error: "이미 관리자 계정이 존재합니다." }); - } - - const password = "auddnek0403!"; - const passwordHash = await bcrypt.hash(password, 10); - - await pool.query( - "INSERT INTO admin_users (username, password_hash) VALUES (?, ?)", - ["admin", passwordHash] - ); - - res.json({ message: "관리자 계정이 생성되었습니다." }); - } catch (error) { - console.error("계정 생성 오류:", error); - res.status(500).json({ error: "계정 생성 중 오류가 발생했습니다." }); - } -}); - -// ==================== 앨범 관리 API ==================== - -// 앨범 생성 -router.post( - "/albums", - authenticateToken, - upload.single("cover"), - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const data = JSON.parse(req.body.data); - const { - title, - album_type, - album_type_short, - release_date, - folder_name, - description, - tracks, - } = data; - - // 필수 필드 검증 - if (!title || !album_type || !release_date || !folder_name) { - return res - .status(400) - .json({ error: "필수 필드를 모두 입력해주세요." }); - } - - let coverOriginalUrl = null; - let coverMediumUrl = null; - let coverThumbUrl = null; - - // 커버 이미지 업로드 (3개 해상도) - if (req.file) { - // 3가지 크기로 변환 (병렬) - const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ - sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), - sharp(req.file.buffer) - .resize(800, null, { withoutEnlargement: true }) - .webp({ quality: 85 }) - .toBuffer(), - sharp(req.file.buffer) - .resize(400, null, { withoutEnlargement: true }) - .webp({ quality: 80 }) - .toBuffer(), - ]); - - const basePath = `album/${folder_name}/cover`; - - // S3 업로드 (병렬) - await Promise.all([ - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/original/cover.webp`, - Body: originalBuffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/medium_800/cover.webp`, - Body: mediumBuffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/thumb_400/cover.webp`, - Body: thumbBuffer, - ContentType: "image/webp", - }) - ), - ]); - - const publicUrl = - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; - coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; - coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; - coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; - } - - // 앨범 삽입 - const [albumResult] = await connection.query( - `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_original_url, cover_medium_url, cover_thumb_url, description) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - title, - album_type, - album_type_short || null, - release_date, - folder_name, - coverOriginalUrl, - coverMediumUrl, - coverThumbUrl, - description || null, - ] - ); - - const albumId = albumResult.insertId; - - // 트랙 삽입 - if (tracks && tracks.length > 0) { - for (const track of tracks) { - await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - track.track_number, - track.title, - track.duration || null, - track.is_title_track ? 1 : 0, - track.lyricist || null, - track.composer || null, - track.arranger || null, - track.lyrics || null, - track.music_video_url || null, - ] - ); - } - } - - await connection.commit(); - - res.json({ message: "앨범이 생성되었습니다.", albumId }); - } catch (error) { - await connection.rollback(); - console.error("앨범 생성 오류:", error); - res.status(500).json({ error: "앨범 생성 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// 앨범 수정 -router.put( - "/albums/:id", - authenticateToken, - upload.single("cover"), - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const albumId = req.params.id; - const data = JSON.parse(req.body.data); - const { - title, - album_type, - album_type_short, - release_date, - folder_name, - description, - tracks, - } = data; - - // 기존 앨범 조회 - const [existingAlbums] = await connection.query( - "SELECT * FROM albums WHERE id = ?", - [albumId] - ); - if (existingAlbums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - let coverOriginalUrl = existingAlbums[0].cover_original_url; - let coverMediumUrl = existingAlbums[0].cover_medium_url; - let coverThumbUrl = existingAlbums[0].cover_thumb_url; - - // 커버 이미지 업로드 (3개 해상도) - if (req.file) { - // 3가지 크기로 변환 (병렬) - const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ - sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), - sharp(req.file.buffer) - .resize(800, null, { withoutEnlargement: true }) - .webp({ quality: 85 }) - .toBuffer(), - sharp(req.file.buffer) - .resize(400, null, { withoutEnlargement: true }) - .webp({ quality: 80 }) - .toBuffer(), - ]); - - const basePath = `album/${folder_name}/cover`; - - // S3 업로드 (병렬) - await Promise.all([ - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/original/cover.webp`, - Body: originalBuffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/medium_800/cover.webp`, - Body: mediumBuffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/thumb_400/cover.webp`, - Body: thumbBuffer, - ContentType: "image/webp", - }) - ), - ]); - - const publicUrl = - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; - coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; - coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; - coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; - } - - // 앨범 업데이트 - await connection.query( - `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_original_url = ?, cover_medium_url = ?, cover_thumb_url = ?, description = ? - WHERE id = ?`, - [ - title, - album_type, - album_type_short || null, - release_date, - folder_name, - coverOriginalUrl, - coverMediumUrl, - coverThumbUrl, - description || null, - albumId, - ] - ); - - // 기존 트랙 삭제 후 새 트랙 삽입 - await connection.query("DELETE FROM tracks WHERE album_id = ?", [ - albumId, - ]); - - if (tracks && tracks.length > 0) { - for (const track of tracks) { - await connection.query( - `INSERT INTO tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - track.track_number, - track.title, - track.duration || null, - track.is_title_track ? 1 : 0, - track.lyricist || null, - track.composer || null, - track.arranger || null, - track.lyrics || null, - track.music_video_url || null, - ] - ); - } - } - - await connection.commit(); - - res.json({ message: "앨범이 수정되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("앨범 수정 오류:", error); - res.status(500).json({ error: "앨범 수정 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// 앨범 삭제 -router.delete("/albums/:id", authenticateToken, async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const albumId = req.params.id; - - // 기존 앨범 조회 - const [existingAlbums] = await connection.query( - "SELECT * FROM albums WHERE id = ?", - [albumId] - ); - if (existingAlbums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const album = existingAlbums[0]; - - // RustFS에서 커버 이미지 삭제 (3가지 크기) - if (album.cover_original_url && album.folder_name) { - const basePath = `album/${album.folder_name}/cover`; - const sizes = ["original", "medium_800", "thumb_400"]; - for (const size of sizes) { - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/${size}/cover.webp`, - }) - ); - } catch (s3Error) { - console.error(`S3 커버 삭제 오류 (${size}):`, s3Error); - } - } - } - - // 트랙 삭제 - await connection.query("DELETE FROM tracks WHERE album_id = ?", [albumId]); - - // 앨범 삭제 - await connection.query("DELETE FROM albums WHERE id = ?", [albumId]); - - await connection.commit(); - - res.json({ message: "앨범이 삭제되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("앨범 삭제 오류:", error); - res.status(500).json({ error: "앨범 삭제 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } -}); - -// ============================================ -// 앨범 사진 관리 API -// ============================================ - -// 앨범 사진 목록 조회 -router.get("/albums/:albumId/photos", async (req, res) => { - try { - const { albumId } = req.params; - - // 앨범 존재 확인 - const [albums] = await pool.query( - "SELECT folder_name FROM albums WHERE id = ?", - [albumId] - ); - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const folderName = albums[0].folder_name; - - // 사진 조회 (멤버 정보 포함) - const [photos] = await pool.query( - ` - SELECT - p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, - p.sort_order, p.width, p.height, p.file_size, - GROUP_CONCAT(pm.member_id) as member_ids - FROM album_photos p - LEFT JOIN album_photo_members pm ON p.id = pm.photo_id - WHERE p.album_id = ? - GROUP BY p.id - ORDER BY p.sort_order ASC - `, - [albumId] - ); - - // 멤버 배열 파싱 - const result = photos.map((photo) => ({ - ...photo, - members: photo.member_ids ? photo.member_ids.split(",").map(Number) : [], - })); - - res.json(result); - } catch (error) { - console.error("사진 조회 오류:", error); - res.status(500).json({ error: "사진 조회 중 오류가 발생했습니다." }); - } -}); - -// 앨범 티저 목록 조회 -router.get("/albums/:albumId/teasers", async (req, res) => { - try { - const { albumId } = req.params; - - // 앨범 존재 확인 - const [albums] = await pool.query( - "SELECT folder_name FROM albums WHERE id = ?", - [albumId] - ); - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - // 티저 조회 - const [teasers] = await pool.query( - `SELECT id, original_url, medium_url, thumb_url, video_url, sort_order, media_type - FROM album_teasers - WHERE album_id = ? - ORDER BY sort_order ASC`, - [albumId] - ); - - res.json(teasers); - } catch (error) { - console.error("티저 조회 오류:", error); - res.status(500).json({ error: "티저 조회 중 오류가 발생했습니다." }); - } -}); - -// 티저 삭제 -router.delete( - "/albums/:albumId/teasers/:teaserId", - authenticateToken, - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const { albumId, teaserId } = req.params; - - // 티저 정보 조회 - const [teasers] = await connection.query( - "SELECT t.*, a.folder_name FROM album_teasers t JOIN albums a ON t.album_id = a.id WHERE t.id = ? AND t.album_id = ?", - [teaserId, albumId] - ); - - if (teasers.length === 0) { - return res.status(404).json({ error: "티저를 찾을 수 없습니다." }); - } - - const teaser = teasers[0]; - const filename = teaser.original_url.split("/").pop(); - const basePath = `album/${teaser.folder_name}/teaser`; - - // RustFS에서 썸네일 삭제 (3가지 크기 모두) - const sizes = ["original", "medium_800", "thumb_400"]; - for (const size of sizes) { - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/${size}/${filename}`, - }) - ); - } catch (s3Error) { - console.error(`S3 삭제 오류 (${size}):`, s3Error); - } - } - - // 비디오 파일 삭제 (video_url이 있는 경우) - if (teaser.video_url) { - const videoFilename = teaser.video_url.split("/").pop(); - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/video/${videoFilename}`, - }) - ); - } catch (s3Error) { - console.error("S3 비디오 삭제 오류:", s3Error); - } - } - - // 티저 삭제 - await connection.query("DELETE FROM album_teasers WHERE id = ?", [ - teaserId, - ]); - - await connection.commit(); - - res.json({ message: "티저가 삭제되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("티저 삭제 오류:", error); - res.status(500).json({ error: "티저 삭제 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// 사진 업로드 (SSE로 실시간 진행률 전송) -router.post( - "/albums/:albumId/photos", - authenticateToken, - upload.array("photos", 200), - async (req, res) => { - // SSE 헤더 설정 - res.setHeader("Content-Type", "text/event-stream"); - res.setHeader("Cache-Control", "no-cache"); - res.setHeader("Connection", "keep-alive"); - - const sendProgress = (current, total, message) => { - res.write(`data: ${JSON.stringify({ current, total, message })}\n\n`); - }; - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const { albumId } = req.params; - const metadata = JSON.parse(req.body.metadata || "[]"); - const startNumber = parseInt(req.body.startNumber) || null; - const photoType = req.body.photoType || "concept"; // 'concept' | 'teaser' - - // 앨범 정보 조회 - const [albums] = await connection.query( - "SELECT folder_name FROM albums WHERE id = ?", - [albumId] - ); - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const folderName = albums[0].folder_name; - - // 시작 번호 결정 (클라이언트 지정 또는 기존 사진 다음 번호) - let nextOrder; - if (startNumber && startNumber > 0) { - nextOrder = startNumber; - } else { - const [existingPhotos] = await connection.query( - "SELECT MAX(sort_order) as maxOrder FROM album_photos WHERE album_id = ?", - [albumId] - ); - nextOrder = (existingPhotos[0].maxOrder || 0) + 1; - } - - const uploadedPhotos = []; - const totalFiles = req.files.length; - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - const meta = metadata[i] || {}; - const orderNum = String(nextOrder + i).padStart(2, "0"); - const isVideo = file.mimetype === "video/mp4"; - const extension = isVideo ? "mp4" : "webp"; - const filename = `${orderNum}.${extension}`; - - // 진행률 전송 - sendProgress(i + 1, totalFiles, `${filename} 처리 중...`); - - let originalUrl, mediumUrl, thumbUrl, videoUrl; - let originalBuffer, originalMeta; - - // 컨셉 포토: photo/, 티저: teaser/ - const subFolder = photoType === "teaser" ? "teaser" : "photo"; - const basePath = `album/${folderName}/${subFolder}`; - - if (isVideo) { - // ===== 비디오 파일 처리 (티저 전용) ===== - const tempDir = os.tmpdir(); - const tempVideoPath = path.join(tempDir, `video_${Date.now()}.mp4`); - const tempThumbPath = path.join(tempDir, `thumb_${Date.now()}.png`); - const thumbFilename = `${orderNum}.webp`; - - try { - // 1. 임시 파일로 MP4 저장 - await fs.writeFile(tempVideoPath, file.buffer); - - // 2. ffmpeg로 첫 프레임 추출 (썸네일) - await new Promise((resolve, reject) => { - ffmpeg(tempVideoPath) - .screenshots({ - timestamps: ["00:00:00.001"], - filename: path.basename(tempThumbPath), - folder: tempDir, - }) - .on("end", resolve) - .on("error", reject); - }); - - // 3. 추출된 썸네일을 Sharp로 3가지 크기로 변환 - const thumbBuffer = await fs.readFile(tempThumbPath); - const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ - sharp(thumbBuffer).webp({ lossless: true }).toBuffer(), - sharp(thumbBuffer) - .resize(800, null, { withoutEnlargement: true }) - .webp({ quality: 85 }) - .toBuffer(), - sharp(thumbBuffer) - .resize(400, null, { withoutEnlargement: true }) - .webp({ quality: 80 }) - .toBuffer(), - ]); - - // 4. 썸네일 이미지들과 MP4 업로드 (병렬) - await Promise.all([ - // 썸네일 original - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/original/${thumbFilename}`, - Body: origBuf, - ContentType: "image/webp", - }) - ), - // 썸네일 medium - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/medium_800/${thumbFilename}`, - Body: medium800Buffer, - ContentType: "image/webp", - }) - ), - // 썸네일 thumb - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/thumb_400/${thumbFilename}`, - Body: thumb400Buffer, - ContentType: "image/webp", - }) - ), - // 원본 MP4 - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/video/${filename}`, - Body: file.buffer, - ContentType: "video/mp4", - }) - ), - ]); - - // 5. URL 설정 (썸네일은 WebP, 비디오는 MP4) - originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${thumbFilename}`; - mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${thumbFilename}`; - thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${thumbFilename}`; - videoUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/video/${filename}`; - } finally { - // 임시 파일 정리 - await fs.unlink(tempVideoPath).catch(() => {}); - await fs.unlink(tempThumbPath).catch(() => {}); - } - } else { - // ===== 이미지 파일 처리 ===== - // Sharp로 이미지 처리 (병렬) - const [origBuf, medium800Buffer, thumb400Buffer] = await Promise.all([ - sharp(file.buffer).webp({ lossless: true }).toBuffer(), - sharp(file.buffer) - .resize(800, null, { withoutEnlargement: true }) - .webp({ quality: 85 }) - .toBuffer(), - sharp(file.buffer) - .resize(400, null, { withoutEnlargement: true }) - .webp({ quality: 80 }) - .toBuffer(), - ]); - - originalBuffer = origBuf; - originalMeta = await sharp(originalBuffer).metadata(); - - // RustFS 업로드 (병렬) - await Promise.all([ - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/original/${filename}`, - Body: originalBuffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/medium_800/${filename}`, - Body: medium800Buffer, - ContentType: "image/webp", - }) - ), - s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/thumb_400/${filename}`, - Body: thumb400Buffer, - ContentType: "image/webp", - }) - ), - ]); - - originalUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/original/${filename}`; - mediumUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/medium_800/${filename}`; - thumbUrl = `${process.env.RUSTFS_PUBLIC_URL}/${BUCKET}/${basePath}/thumb_400/${filename}`; - } - - let photoId; - - // DB 저장 - 티저와 컨셉 포토 분기 - if (photoType === "teaser") { - // 티저 이미지/비디오 → album_teasers 테이블 - const mediaType = isVideo ? "video" : "image"; - const [result] = await connection.query( - `INSERT INTO album_teasers - (album_id, original_url, medium_url, thumb_url, video_url, sort_order, media_type) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - originalUrl, - mediumUrl, - thumbUrl, - videoUrl || null, - nextOrder + i, - mediaType, - ] - ); - photoId = result.insertId; - } else { - // 컨셉 포토 → album_photos 테이블 (이미지만) - const [result] = await connection.query( - `INSERT INTO album_photos - (album_id, original_url, medium_url, thumb_url, photo_type, concept_name, sort_order, width, height, file_size) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - albumId, - originalUrl, - mediumUrl, - thumbUrl, - meta.groupType || "group", - meta.conceptName || null, - nextOrder + i, - originalMeta.width, - originalMeta.height, - originalBuffer.length, - ] - ); - photoId = result.insertId; - - // 멤버 태깅 저장 (컨셉 포토만) - if (meta.members && meta.members.length > 0) { - for (const memberId of meta.members) { - await connection.query( - "INSERT INTO album_photo_members (photo_id, member_id) VALUES (?, ?)", - [photoId, memberId] - ); - } - } - } - - uploadedPhotos.push({ - id: photoId, - original_url: originalUrl, - medium_url: mediumUrl, - thumb_url: thumbUrl, - video_url: videoUrl || null, - filename, - media_type: isVideo ? "video" : "image", - }); - } - - await connection.commit(); - - // 완료 이벤트 전송 - res.write( - `data: ${JSON.stringify({ - done: true, - message: `${uploadedPhotos.length}개의 사진이 업로드되었습니다.`, - photos: uploadedPhotos, - })}\n\n` - ); - res.end(); - } catch (error) { - await connection.rollback(); - console.error("사진 업로드 오류:", error); - res.write( - `data: ${JSON.stringify({ - error: "사진 업로드 중 오류가 발생했습니다.", - })}\n\n` - ); - res.end(); - } finally { - connection.release(); - } - } -); - -// 사진 삭제 -router.delete( - "/albums/:albumId/photos/:photoId", - authenticateToken, - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const { albumId, photoId } = req.params; - - // 사진 정보 조회 - const [photos] = await connection.query( - "SELECT p.*, a.folder_name FROM album_photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND p.album_id = ?", - [photoId, albumId] - ); - - if (photos.length === 0) { - return res.status(404).json({ error: "사진을 찾을 수 없습니다." }); - } - - const photo = photos[0]; - const filename = photo.original_url.split("/").pop(); - const basePath = `album/${photo.folder_name}/photo`; - - // RustFS에서 삭제 (3가지 크기 모두) - const sizes = ["original", "medium_800", "thumb_400"]; - for (const size of sizes) { - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/${size}/${filename}`, - }) - ); - } catch (s3Error) { - console.error(`S3 삭제 오류 (${size}):`, s3Error); - } - } - - // 멤버 태깅 삭제 - await connection.query( - "DELETE FROM album_photo_members WHERE photo_id = ?", - [photoId] - ); - - // 사진 삭제 - await connection.query("DELETE FROM album_photos WHERE id = ?", [ - photoId, - ]); - - await connection.commit(); - - res.json({ message: "사진이 삭제되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("사진 삭제 오류:", error); - res.status(500).json({ error: "사진 삭제 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// ==================== 멤버 관리 API ==================== - -// 멤버 상세 조회 (이름으로) -router.get("/members/:name", authenticateToken, async (req, res) => { - try { - const memberName = decodeURIComponent(req.params.name); - const [members] = await pool.query("SELECT * FROM members WHERE name = ?", [ - memberName, - ]); - - if (members.length === 0) { - return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); - } - - res.json(members[0]); - } catch (error) { - console.error("멤버 조회 오류:", error); - res.status(500).json({ error: "멤버 조회 중 오류가 발생했습니다." }); - } -}); - -// 멤버 수정 (이름으로) -router.put( - "/members/:name", - authenticateToken, - upload.single("image"), - async (req, res) => { - try { - const memberName = decodeURIComponent(req.params.name); - const { name, birth_date, position, instagram, is_former } = req.body; - - // 기존 멤버 확인 - const [existing] = await pool.query( - "SELECT * FROM members WHERE name = ?", - [memberName] - ); - if (existing.length === 0) { - return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); - } - - const memberId = existing[0].id; - - let imageUrl = existing[0].image_url; - - // 새 이미지 업로드 - if (req.file) { - const webpBuffer = await sharp(req.file.buffer) - .webp({ quality: 90 }) - .toBuffer(); - - const key = `member/${memberId}/profile.webp`; - - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: key, - Body: webpBuffer, - ContentType: "image/webp", - }) - ); - - const publicUrl = - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; - imageUrl = `${publicUrl}/${BUCKET}/${key}`; - } - - // 멤버 업데이트 - await pool.query( - `UPDATE members SET - name = ?, - birth_date = ?, - position = ?, - instagram = ?, - is_former = ?, - image_url = ? - WHERE id = ?`, - [ - name, - birth_date || null, - position || null, - instagram || null, - is_former === "true" || is_former === true ? 1 : 0, - imageUrl, - memberId, - ] - ); - - res.json({ message: "멤버 정보가 수정되었습니다." }); - } catch (error) { - console.error("멤버 수정 오류:", error); - res.status(500).json({ error: "멤버 수정 중 오류가 발생했습니다." }); - } - } -); - -// ==================== 일정 카테고리 관리 API ==================== - -// 카테고리 목록 조회 (인증 불필요 - 폼에서 사용) -router.get("/schedule-categories", async (req, res) => { - try { - const [categories] = await pool.query( - "SELECT * FROM schedule_categories ORDER BY sort_order ASC" - ); - res.json(categories); - } catch (error) { - console.error("카테고리 조회 오류:", error); - res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); - } -}); - -// 카테고리 생성 -router.post("/schedule-categories", authenticateToken, async (req, res) => { - try { - const { name, color } = req.body; - - if (!name || !color) { - return res.status(400).json({ error: "이름과 색상은 필수입니다." }); - } - - // 현재 최대 sort_order 조회 - const [maxOrder] = await pool.query( - "SELECT MAX(sort_order) as maxOrder FROM schedule_categories" - ); - const nextOrder = (maxOrder[0].maxOrder || 0) + 1; - - const [result] = await pool.query( - "INSERT INTO schedule_categories (name, color, sort_order) VALUES (?, ?, ?)", - [name, color, nextOrder] - ); - - res.json({ - message: "카테고리가 생성되었습니다.", - id: result.insertId, - sort_order: nextOrder, - }); - } catch (error) { - console.error("카테고리 생성 오류:", error); - res.status(500).json({ error: "카테고리 생성 중 오류가 발생했습니다." }); - } -}); - -// 카테고리 수정 -router.put("/schedule-categories/:id", authenticateToken, async (req, res) => { - try { - const { id } = req.params; - const { name, color, sort_order } = req.body; - - const [existing] = await pool.query( - "SELECT * FROM schedule_categories WHERE id = ?", - [id] - ); - if (existing.length === 0) { - return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); - } - - await pool.query( - "UPDATE schedule_categories SET name = ?, color = ?, sort_order = ? WHERE id = ?", - [ - name || existing[0].name, - color || existing[0].color, - sort_order !== undefined ? sort_order : existing[0].sort_order, - id, - ] - ); - - res.json({ message: "카테고리가 수정되었습니다." }); - } catch (error) { - console.error("카테고리 수정 오류:", error); - res.status(500).json({ error: "카테고리 수정 중 오류가 발생했습니다." }); - } -}); - -// 카테고리 삭제 -router.delete( - "/schedule-categories/:id", - authenticateToken, - async (req, res) => { - try { - const { id } = req.params; - - const [existing] = await pool.query( - "SELECT * FROM schedule_categories WHERE id = ?", - [id] - ); - if (existing.length === 0) { - return res.status(404).json({ error: "카테고리를 찾을 수 없습니다." }); - } - - // 기본 카테고리는 삭제 불가 - if (existing[0].is_default === 1) { - return res - .status(400) - .json({ error: "기본 카테고리는 삭제할 수 없습니다." }); - } - - // 해당 카테고리를 사용하는 일정이 있는지 확인 - const [usedSchedules] = await pool.query( - "SELECT COUNT(*) as count FROM schedules WHERE category_id = ?", - [id] - ); - if (usedSchedules[0].count > 0) { - return res.status(400).json({ - error: `해당 카테고리를 사용하는 일정이 ${usedSchedules[0].count}개 있어 삭제할 수 없습니다.`, - }); - } - - await pool.query("DELETE FROM schedule_categories WHERE id = ?", [id]); - - res.json({ message: "카테고리가 삭제되었습니다." }); - } catch (error) { - console.error("카테고리 삭제 오류:", error); - res.status(500).json({ error: "카테고리 삭제 중 오류가 발생했습니다." }); - } - } -); - -// 카테고리 순서 일괄 업데이트 -router.put( - "/schedule-categories-order", - authenticateToken, - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const { orders } = req.body; // [{ id: 1, sort_order: 1 }, { id: 2, sort_order: 2 }, ...] - - if (!orders || !Array.isArray(orders)) { - return res.status(400).json({ error: "순서 데이터가 필요합니다." }); - } - - for (const item of orders) { - await connection.query( - "UPDATE schedule_categories SET sort_order = ? WHERE id = ?", - [item.sort_order, item.id] - ); - } - - await connection.commit(); - res.json({ message: "순서가 업데이트되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("순서 업데이트 오류:", error); - res.status(500).json({ error: "순서 업데이트 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); -// ==================== 일정 관리 API ==================== - -// 일정 목록 조회 -router.get("/schedules", async (req, res) => { - try { - const { year, month, search } = req.query; - - let whereConditions = []; - let params = []; - - // 검색어가 있으면 전체 일정에서 검색 (년/월 필터 무시) - if (search && search.trim()) { - const searchTerm = `%${search.trim()}%`; - whereConditions.push("(s.title LIKE ? OR s.description LIKE ?)"); - params.push(searchTerm, searchTerm); - } else { - // 년/월 필터링 (검색이 아닐 때만) - if (year && month) { - whereConditions.push("YEAR(s.date) = ? AND MONTH(s.date) = ?"); - params.push(parseInt(year), parseInt(month)); - } else if (year) { - whereConditions.push("YEAR(s.date) = ?"); - params.push(parseInt(year)); - } - } - - const whereClause = - whereConditions.length > 0 - ? `WHERE ${whereConditions.join(" AND ")}` - : ""; - - const [schedules] = await pool.query( - `SELECT - s.id, s.title, s.date, s.time, s.end_date, s.end_time, - s.category_id, s.description, s.source_url, s.source_name, - s.location_name, s.location_address, s.location_detail, s.location_lat, s.location_lng, - s.created_at, - c.name as category_name, c.color as category_color - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - ${whereClause} - ORDER BY s.date ASC, s.time ASC`, - params - ); - - // 각 일정의 이미지와 멤버 조회 - const schedulesWithDetails = await Promise.all( - schedules.map(async (schedule) => { - const [images] = await pool.query( - "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", - [schedule.id] - ); - const [members] = await pool.query( - `SELECT m.id, m.name FROM schedule_members sm - JOIN members m ON sm.member_id = m.id - WHERE sm.schedule_id = ?`, - [schedule.id] - ); - return { ...schedule, images, members }; - }) - ); - - // 년/월 필터가 있고 검색이 아닌 경우 생일 데이터 추가 - if (year && month && !search) { - const [birthdays] = await pool.query( - `SELECT id, name, name_en, birth_date, image_url - FROM members - WHERE is_former = 0 AND MONTH(birth_date) = ?`, - [parseInt(month)] - ); - - const birthdaySchedules = birthdays.map((member) => { - const birthDate = new Date(member.birth_date); - const birthdayThisYear = new Date( - parseInt(year), - birthDate.getMonth(), - birthDate.getDate() - ); - - return { - id: `birthday-${member.id}`, - title: `HAPPY ${member.name_en} DAY`, - description: null, - date: birthdayThisYear, - time: null, - end_date: null, - end_time: null, - category_id: 8, - source_url: null, - source_name: null, - location_name: null, - location_address: null, - location_detail: null, - location_lat: null, - location_lng: null, - created_at: null, - category_name: "생일", - category_color: "#f472b6", - images: [], - members: [{ id: member.id, name: member.name }], - member_names: member.name, - is_birthday: true, - member_image: member.image_url, - }; - }); - - // 일정과 생일을 합쳐서 날짜순 정렬 - const allSchedules = [...schedulesWithDetails, ...birthdaySchedules].sort( - (a, b) => new Date(a.date) - new Date(b.date) - ); - - return res.json(allSchedules); - } - - res.json(schedulesWithDetails); - } catch (error) { - console.error("일정 조회 오류:", error); - res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); - } -}); - -// 일정 생성 -router.post( - "/schedules", - authenticateToken, - upload.array("images", 20), - async (req, res) => { - const connection = await pool.getConnection(); - - try { - await connection.beginTransaction(); - - const data = JSON.parse(req.body.data); - const { - title, - date, - time, - endDate, - endTime, - isRange, - category, - description, - url, - sourceName, - members, - locationName, - locationAddress, - locationDetail, - locationLat, - locationLng, - } = data; - - // 필수 필드 검증 - if (!title || !date) { - return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); - } - - // 일정 삽입 - const [scheduleResult] = await connection.query( - `INSERT INTO schedules - (title, date, time, end_date, end_time, category_id, description, source_url, source_name, - location_name, location_address, location_detail, location_lat, location_lng) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - title, - date, - time || null, - isRange && endDate ? endDate : null, - isRange && endTime ? endTime : null, - category || null, - description || null, - url || null, - sourceName || null, - locationName || null, - locationAddress || null, - locationDetail || null, - locationLat || null, - locationLng || null, - ] - ); - - const scheduleId = scheduleResult.insertId; - - // 멤버 연결 처리 (schedule_members 테이블) - if (members && members.length > 0) { - const memberValues = members.map((memberId) => [scheduleId, memberId]); - await connection.query( - `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, - [memberValues] - ); - } - - // 이미지 업로드 처리 - if (req.files && req.files.length > 0) { - const publicUrl = - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; - const basePath = `schedule/${scheduleId}`; - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - const orderNum = String(i + 1).padStart(2, "0"); - const filename = `${orderNum}.webp`; - - // WebP 변환 (원본만) - const imageBuffer = await sharp(file.buffer) - .webp({ quality: 90 }) - .toBuffer(); - - // RustFS 업로드 (원본만) - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: `${basePath}/${filename}`, - Body: imageBuffer, - ContentType: "image/webp", - }) - ); - - const imageUrl = `${publicUrl}/${BUCKET}/${basePath}/${filename}`; - - // DB 저장 - await connection.query( - `INSERT INTO schedule_images (schedule_id, image_url, sort_order) - VALUES (?, ?, ?)`, - [scheduleId, imageUrl, i + 1] - ); - } - } - - await connection.commit(); - - // Meilisearch에 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [category || null] - ); - const [memberInfo] = await pool.query( - "SELECT id, name FROM members WHERE id IN (?)", - [members?.length ? members : [0]] - ); - await addOrUpdateSchedule({ - id: scheduleId, - title, - description, - date, - time, - category_id: category, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: sourceName, - source_url: url, - members: memberInfo, - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - res.json({ message: "일정이 생성되었습니다.", scheduleId }); - } catch (error) { - await connection.rollback(); - console.error("일정 생성 오류:", error); - res.status(500).json({ error: "일정 생성 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// 카카오 장소 검색 프록시 (CORS 우회) -router.get("/kakao/places", authenticateToken, async (req, res) => { - try { - const { query } = req.query; - - if (!query) { - return res.status(400).json({ error: "검색어를 입력해주세요." }); - } - - const response = await fetch( - `https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent( - query - )}`, - { - headers: { - Authorization: `KakaoAK ${process.env.KAKAO_REST_KEY}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Kakao API error: ${response.status}`); - } - - const data = await response.json(); - res.json(data); - } catch (error) { - console.error("카카오 장소 검색 오류:", error); - res.status(500).json({ error: "장소 검색 중 오류가 발생했습니다." }); - } -}); - -// 일정 단일 조회 -router.get("/schedules/:id", authenticateToken, async (req, res) => { - try { - const { id } = req.params; - - // 일정 기본 정보 조회 - const [schedules] = await pool.query( - `SELECT s.*, sc.name as category_name, sc.color as category_color - FROM schedules s - LEFT JOIN schedule_categories sc ON s.category_id = sc.id - WHERE s.id = ?`, - [id] - ); - - if (schedules.length === 0) { - return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); - } - - const schedule = schedules[0]; - - // 이미지 조회 - const [images] = await pool.query( - "SELECT id, image_url, sort_order FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", - [id] - ); - - // 멤버 조회 - const [members] = await pool.query( - `SELECT m.id, m.name, m.image_url - FROM schedule_members sm - JOIN members m ON sm.member_id = m.id - WHERE sm.schedule_id = ?`, - [id] - ); - - res.json({ ...schedule, images, members }); - } catch (error) { - console.error("일정 조회 오류:", error); - res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); - } -}); - -// 일정 수정 -router.put( - "/schedules/:id", - authenticateToken, - upload.array("images", 20), - async (req, res) => { - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - - const { id } = req.params; - const data = JSON.parse(req.body.data || "{}"); - const { - title, - date, - time, - endDate, - endTime, - isRange, - category, - description, - url, - sourceName, - members, - locationName, - locationAddress, - locationDetail, - locationLat, - locationLng, - existingImages, // 유지할 기존 이미지 ID 배열 - } = data; - - // 필수 필드 검증 - if (!title || !date) { - return res.status(400).json({ error: "제목과 날짜를 입력해주세요." }); - } - - // 일정 업데이트 - await connection.query( - `UPDATE schedules SET - title = ?, - date = ?, - time = ?, - end_date = ?, - end_time = ?, - category_id = ?, - description = ?, - source_url = ?, - source_name = ?, - location_name = ?, - location_address = ?, - location_detail = ?, - location_lat = ?, - location_lng = ? - WHERE id = ?`, - [ - title, - date, - time || null, - isRange && endDate ? endDate : null, - isRange && endTime ? endTime : null, - category || null, - description || null, - url || null, - sourceName || null, - locationName || null, - locationAddress || null, - locationDetail || null, - locationLat || null, - locationLng || null, - id, - ] - ); - - // 멤버 업데이트 (기존 삭제 후 새로 추가) - await connection.query( - "DELETE FROM schedule_members WHERE schedule_id = ?", - [id] - ); - if (members && members.length > 0) { - const memberValues = members.map((memberId) => [id, memberId]); - await connection.query( - `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, - [memberValues] - ); - } - - // 삭제할 이미지 처리 (existingImages에 없는 이미지 삭제) - const existingImageIds = existingImages || []; - if (existingImageIds.length > 0) { - // 삭제할 이미지 조회 - const [imagesToDelete] = await connection.query( - `SELECT id, image_url FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, - [id, existingImageIds] - ); - - // S3에서 이미지 삭제 - for (const img of imagesToDelete) { - try { - const key = img.image_url.replace( - `${ - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT - }/${process.env.RUSTFS_BUCKET}/`, - "" - ); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.RUSTFS_BUCKET, - Key: key, - }) - ); - } catch (err) { - console.error("이미지 삭제 오류:", err); - } - } - - // DB에서 삭제 - await connection.query( - `DELETE FROM schedule_images WHERE schedule_id = ? AND id NOT IN (?)`, - [id, existingImageIds] - ); - } else { - // 기존 이미지 모두 삭제 - const [allImages] = await connection.query( - `SELECT id, image_url FROM schedule_images WHERE schedule_id = ?`, - [id] - ); - - for (const img of allImages) { - try { - const key = img.image_url.replace( - `${ - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT - }/${process.env.RUSTFS_BUCKET}/`, - "" - ); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.RUSTFS_BUCKET, - Key: key, - }) - ); - } catch (err) { - console.error("이미지 삭제 오류:", err); - } - } - - await connection.query( - `DELETE FROM schedule_images WHERE schedule_id = ?`, - [id] - ); - } - - // 새 이미지 업로드 - if (req.files && req.files.length > 0) { - const publicUrl = - process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; - const basePath = `schedule/${id}`; - - // 현재 최대 sort_order 조회 - const [maxOrder] = await connection.query( - "SELECT COALESCE(MAX(sort_order), 0) as max_order FROM schedule_images WHERE schedule_id = ?", - [id] - ); - let currentOrder = maxOrder[0].max_order; - - for (let i = 0; i < req.files.length; i++) { - const file = req.files[i]; - currentOrder++; - const orderNum = String(currentOrder).padStart(2, "0"); - // 파일명: 01.webp, 02.webp 형식 (Date.now() 제거) - const filename = `${orderNum}.webp`; - - const imageBuffer = await sharp(file.buffer) - .webp({ quality: 90 }) - .toBuffer(); - - await s3Client.send( - new PutObjectCommand({ - Bucket: process.env.RUSTFS_BUCKET, - Key: `${basePath}/${filename}`, - Body: imageBuffer, - ContentType: "image/webp", - }) - ); - - const imageUrl = `${publicUrl}/${process.env.RUSTFS_BUCKET}/${basePath}/${filename}`; - - await connection.query( - "INSERT INTO schedule_images (schedule_id, image_url, sort_order) VALUES (?, ?, ?)", - [id, imageUrl, currentOrder] - ); - } - } - - // sort_order 재정렬 (삭제로 인한 간격 제거) - const [remainingImages] = await connection.query( - "SELECT id FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC", - [id] - ); - for (let i = 0; i < remainingImages.length; i++) { - await connection.query( - "UPDATE schedule_images SET sort_order = ? WHERE id = ?", - [i + 1, remainingImages[i].id] - ); - } - - await connection.commit(); - - // Meilisearch 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [category || null] - ); - const [memberInfo] = await pool.query( - "SELECT id, name FROM members WHERE id IN (?)", - [members?.length ? members : [0]] - ); - await addOrUpdateSchedule({ - id: parseInt(id), - title, - description, - date, - time, - category_id: category, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: sourceName, - source_url: url, - members: memberInfo, - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - res.json({ message: "일정이 수정되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("일정 수정 오류:", error); - res.status(500).json({ error: "일정 수정 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } - } -); - -// 일정 삭제 -router.delete("/schedules/:id", authenticateToken, async (req, res) => { - const connection = await pool.getConnection(); - try { - await connection.beginTransaction(); - - const { id } = req.params; - - // 이미지 조회 - const [images] = await connection.query( - "SELECT image_url FROM schedule_images WHERE schedule_id = ?", - [id] - ); - - // S3에서 이미지 삭제 - for (const img of images) { - try { - const key = img.image_url.replace( - `${process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT}/${ - process.env.RUSTFS_BUCKET - }/`, - "" - ); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.RUSTFS_BUCKET, - Key: key, - }) - ); - } catch (err) { - console.error("이미지 삭제 오류:", err); - } - } - - // 일정 삭제 (CASCADE로 schedule_images, schedule_members도 자동 삭제) - await connection.query("DELETE FROM schedules WHERE id = ?", [id]); - - await connection.commit(); - - // Meilisearch에서도 삭제 - try { - await deleteScheduleFromSearch(id); - } catch (searchError) { - console.error("Meilisearch 삭제 오류:", searchError.message); - } - - res.json({ message: "일정이 삭제되었습니다." }); - } catch (error) { - await connection.rollback(); - console.error("일정 삭제 오류:", error); - res.status(500).json({ error: "일정 삭제 중 오류가 발생했습니다." }); - } finally { - connection.release(); - } -}); - -// ===================================================== -// YouTube 봇 API -// ===================================================== - -// 봇 목록 조회 -router.get("/bots", authenticateToken, async (req, res) => { - try { - const [bots] = await pool.query(` - SELECT b.*, - yc.channel_id, yc.channel_name, - xc.username, xc.nitter_url - FROM bots b - LEFT JOIN bot_youtube_config yc ON b.id = yc.bot_id - LEFT JOIN bot_x_config xc ON b.id = xc.bot_id - ORDER BY b.id ASC - `); - res.json(bots); - } catch (error) { - console.error("봇 목록 조회 오류:", error); - res.status(500).json({ error: "봇 목록 조회 중 오류가 발생했습니다." }); - } -}); - -// 봇 시작 -router.post("/bots/:id/start", authenticateToken, async (req, res) => { - try { - const { id } = req.params; - await startBot(id); - res.json({ message: "봇이 시작되었습니다." }); - } catch (error) { - console.error("봇 시작 오류:", error); - res - .status(500) - .json({ error: error.message || "봇 시작 중 오류가 발생했습니다." }); - } -}); - -// 봇 정지 -router.post("/bots/:id/stop", authenticateToken, async (req, res) => { - try { - const { id } = req.params; - await stopBot(id); - res.json({ message: "봇이 정지되었습니다." }); - } catch (error) { - console.error("봇 정지 오류:", error); - res - .status(500) - .json({ error: error.message || "봇 정지 중 오류가 발생했습니다." }); - } -}); - -// 전체 동기화 (초기화) -router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => { - try { - const { id } = req.params; - - // 봇 타입 조회 - const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [id]); - if (bots.length === 0) { - return res.status(404).json({ error: "봇을 찾을 수 없습니다." }); - } - - const botType = bots[0].type; - let result; - - if (botType === "youtube") { - result = await syncAllVideos(id); - } else if (botType === "x") { - result = await syncAllTweets(id); - } else if (botType === "meilisearch") { - result = await syncAllSchedules(id); - } else { - return res - .status(400) - .json({ error: `지원하지 않는 봇 타입: ${botType}` }); - } - - res.json({ - message: `${result.addedCount}개 일정이 추가되었습니다.`, - addedCount: result.addedCount, - total: result.total, - }); - } catch (error) { - console.error("전체 동기화 오류:", error); - res - .status(500) - .json({ error: error.message || "전체 동기화 중 오류가 발생했습니다." }); - } -}); - -// ===================================================== -// YouTube API 할당량 경고 Webhook -// ===================================================== - -// 메모리에 경고 상태 저장 (서버 재시작 시 초기화) -let quotaWarning = { - active: false, - timestamp: null, - message: null, - stoppedBots: [], // 할당량 초과로 중지된 봇 ID 목록 -}; - -// 자정 재시작 타이머 -let quotaResetTimer = null; - -/** - * 자정(LA 시간)에 봇 재시작 예약 - * YouTube 할당량은 LA 태평양 시간 자정에 리셋됨 - */ -async function scheduleQuotaReset() { - // 기존 타이머 취소 - if (quotaResetTimer) { - clearTimeout(quotaResetTimer); - } - - // LA 시간으로 다음 자정 계산 - const now = new Date(); - const laTime = new Date( - now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) - ); - const tomorrow = new Date(laTime); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 1, 0, 0); // 자정 1분 후 (안전 마진) - - // 현재 LA 시간과 다음 자정까지의 밀리초 계산 - const nowLA = new Date( - now.toLocaleString("en-US", { timeZone: "America/Los_Angeles" }) - ); - const msUntilReset = tomorrow.getTime() - nowLA.getTime(); - - console.log( - `[Quota Reset] ${Math.round( - msUntilReset / 1000 / 60 - )}분 후 봇 재시작 예약됨` - ); - - quotaResetTimer = setTimeout(async () => { - console.log("[Quota Reset] 할당량 리셋 시간 도달, 봇 재시작 중..."); - - try { - // 할당량 초과로 중지된 봇들만 재시작 - for (const botId of quotaWarning.stoppedBots) { - await startBot(botId); - console.log(`[Quota Reset] Bot ${botId} 재시작됨`); - } - - // 경고 상태 초기화 - quotaWarning = { - active: false, - timestamp: null, - message: null, - stoppedBots: [], - }; - - console.log("[Quota Reset] 모든 봇 재시작 완료, 경고 상태 초기화"); - } catch (error) { - console.error("[Quota Reset] 봇 재시작 오류:", error.message); - } - }, msUntilReset); -} - -// Webhook 인증 정보 -const WEBHOOK_USERNAME = "fromis9_quota_webhook"; -const WEBHOOK_PASSWORD = "Qw8$kLm3nP2xVr7tYz!9"; - -// Basic Auth 검증 미들웨어 -const verifyWebhookAuth = (req, res, next) => { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith("Basic ")) { - return res.status(401).json({ error: "인증이 필요합니다." }); - } - - const base64Credentials = authHeader.split(" ")[1]; - const credentials = Buffer.from(base64Credentials, "base64").toString( - "utf-8" - ); - const [username, password] = credentials.split(":"); - - if (username !== WEBHOOK_USERNAME || password !== WEBHOOK_PASSWORD) { - return res.status(401).json({ error: "인증 실패" }); - } - - next(); -}; - -// Google Cloud 할당량 경고 Webhook 수신 -router.post("/quota-alert", verifyWebhookAuth, async (req, res) => { - console.log("[Quota Alert] Google Cloud에서 할당량 경고 수신:", req.body); - - quotaWarning = { - active: true, - timestamp: new Date().toISOString(), - message: - "일일 할당량의 95%를 사용했습니다. (9,500 / 10,000 units) - 봇이 자동 중지되었습니다.", - }; - - // 모든 실행 중인 봇 중지 - try { - const [runningBots] = await pool.query( - "SELECT id, name FROM bots WHERE status = 'running'" - ); - - // 중지된 봇 ID 저장 (자정에 재시작용) - quotaWarning.stoppedBots = runningBots.map((bot) => bot.id); - - for (const bot of runningBots) { - await stopBot(bot.id); - console.log(`[Quota Alert] Bot ${bot.name} 중지됨`); - } - console.log( - `[Quota Alert] ${runningBots.length}개 봇이 할당량 초과로 중지됨` - ); - - // 자정에 봇 재시작 예약 (LA 시간 기준 = YouTube 할당량 리셋 시간) - scheduleQuotaReset(); - } catch (error) { - console.error("[Quota Alert] 봇 중지 오류:", error.message); - } - - res - .status(200) - .json({ success: true, message: "경고가 등록되고 봇이 중지되었습니다." }); -}); - -// 할당량 경고 상태 조회 (프론트엔드용) -router.get("/quota-warning", authenticateToken, (req, res) => { - res.json(quotaWarning); -}); - -// 할당량 경고 해제 (수동) -router.delete("/quota-warning", authenticateToken, (req, res) => { - quotaWarning = { - active: false, - timestamp: null, - message: null, - }; - res.json({ success: true, message: "경고가 해제되었습니다." }); -}); - -export default router; diff --git a/backend/routes/albums.js b/backend/routes/albums.js deleted file mode 100644 index 4eed765..0000000 --- a/backend/routes/albums.js +++ /dev/null @@ -1,180 +0,0 @@ -import express from "express"; -import pool from "../lib/db.js"; - -const router = express.Router(); - -// 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) -async function getAlbumDetails(album) { - // 트랙 정보 조회 (가사 포함) - const [tracks] = await pool.query( - "SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number", - [album.id] - ); - album.tracks = tracks; - - // 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함) - const [teasers] = await pool.query( - "SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order", - [album.id] - ); - album.teasers = teasers; - - // 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함) - const [photos] = await pool.query( - `SELECT - p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, - p.width, p.height, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members - FROM album_photos p - LEFT JOIN album_photo_members pm ON p.id = pm.photo_id - LEFT JOIN members m ON pm.member_id = m.id - WHERE p.album_id = ? - GROUP BY p.id - ORDER BY p.sort_order`, - [album.id] - ); - - // 컨셉별로 그룹화 - const conceptPhotos = {}; - for (const photo of photos) { - const concept = photo.concept_name || "Default"; - if (!conceptPhotos[concept]) { - conceptPhotos[concept] = []; - } - conceptPhotos[concept].push({ - id: photo.id, - original_url: photo.original_url, - medium_url: photo.medium_url, - thumb_url: photo.thumb_url, - width: photo.width, - height: photo.height, - type: photo.photo_type, - members: photo.members, - sortOrder: photo.sort_order, - }); - } - album.conceptPhotos = conceptPhotos; - - return album; -} - -// 전체 앨범 조회 (트랙 포함) -router.get("/", async (req, res) => { - try { - const [albums] = await pool.query( - "SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC" - ); - - // 각 앨범에 트랙 정보 추가 - for (const album of albums) { - const [tracks] = await pool.query( - "SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM tracks WHERE album_id = ? ORDER BY track_number", - [album.id] - ); - album.tracks = tracks; - } - - res.json(albums); - } catch (error) { - console.error("앨범 조회 오류:", error); - res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." }); - } -}); - -// 앨범명과 트랙명으로 트랙 상세 조회 (더 구체적인 경로이므로 /by-name/:name보다 앞에 배치) -router.get("/by-name/:albumName/track/:trackTitle", async (req, res) => { - try { - const albumName = decodeURIComponent(req.params.albumName); - const trackTitle = decodeURIComponent(req.params.trackTitle); - - // 앨범 조회 - const [albums] = await pool.query( - "SELECT * FROM albums WHERE folder_name = ? OR title = ?", - [albumName, albumName] - ); - - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const album = albums[0]; - - // 해당 앨범의 트랙 조회 - const [tracks] = await pool.query( - "SELECT * FROM tracks WHERE album_id = ? AND title = ?", - [album.id, trackTitle] - ); - - if (tracks.length === 0) { - return res.status(404).json({ error: "트랙을 찾을 수 없습니다." }); - } - - const track = tracks[0]; - - // 앨범의 다른 트랙 목록 조회 - const [otherTracks] = await pool.query( - "SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number", - [album.id] - ); - - res.json({ - ...track, - album: { - id: album.id, - title: album.title, - folder_name: album.folder_name, - cover_thumb_url: album.cover_thumb_url, - cover_medium_url: album.cover_medium_url, - release_date: album.release_date, - album_type: album.album_type, - }, - otherTracks, - }); - } catch (error) { - console.error("트랙 조회 오류:", error); - res.status(500).json({ error: "트랙 정보를 가져오는데 실패했습니다." }); - } -}); - -// 앨범 folder_name 또는 title로 조회 -router.get("/by-name/:name", async (req, res) => { - try { - const name = decodeURIComponent(req.params.name); - // folder_name 또는 title로 검색 (PC는 title, 모바일은 folder_name 사용) - const [albums] = await pool.query( - "SELECT * FROM albums WHERE folder_name = ? OR title = ?", - [name, name] - ); - - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const album = await getAlbumDetails(albums[0]); - res.json(album); - } catch (error) { - console.error("앨범 조회 오류:", error); - res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." }); - } -}); - -// ID로 앨범 조회 -router.get("/:id", async (req, res) => { - try { - const [albums] = await pool.query("SELECT * FROM albums WHERE id = ?", [ - req.params.id, - ]); - - if (albums.length === 0) { - return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); - } - - const album = await getAlbumDetails(albums[0]); - res.json(album); - } catch (error) { - console.error("앨범 조회 오류:", error); - res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." }); - } -}); - -export default router; diff --git a/backend/routes/members.js b/backend/routes/members.js deleted file mode 100644 index b5e7f3c..0000000 --- a/backend/routes/members.js +++ /dev/null @@ -1,35 +0,0 @@ -import express from "express"; -import pool from "../lib/db.js"; - -const router = express.Router(); - -// 전체 멤버 조회 -router.get("/", async (req, res) => { - try { - const [rows] = await pool.query( - "SELECT id, name, birth_date, position, image_url, instagram, is_former FROM members ORDER BY is_former, birth_date" - ); - res.json(rows); - } catch (error) { - console.error("멤버 조회 오류:", error); - res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." }); - } -}); - -// 특정 멤버 조회 -router.get("/:id", async (req, res) => { - try { - const [rows] = await pool.query("SELECT * FROM members WHERE id = ?", [ - req.params.id, - ]); - if (rows.length === 0) { - return res.status(404).json({ error: "멤버를 찾을 수 없습니다." }); - } - res.json(rows[0]); - } catch (error) { - console.error("멤버 조회 오류:", error); - res.status(500).json({ error: "멤버 정보를 가져오는데 실패했습니다." }); - } -}); - -export default router; diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js deleted file mode 100644 index c33e713..0000000 --- a/backend/routes/schedules.js +++ /dev/null @@ -1,292 +0,0 @@ -import express from "express"; -import pool from "../lib/db.js"; -import { searchSchedules } from "../services/meilisearch.js"; -import { saveSearchQuery, getSuggestions } from "../services/suggestions.js"; -import { getXProfile } from "../services/x-bot.js"; - -const router = express.Router(); - -// 검색어 추천 API (Bi-gram 기반) -router.get("/suggestions", async (req, res) => { - try { - const { q, limit } = req.query; - - if (!q || q.trim().length === 0) { - return res.json({ suggestions: [] }); - } - - const suggestions = await getSuggestions(q, parseInt(limit) || 10); - res.json({ suggestions }); - } catch (error) { - console.error("추천 검색어 오류:", error); - res.status(500).json({ error: "추천 검색어 조회 중 오류가 발생했습니다." }); - } -}); - -// 공개 일정 목록 조회 (검색 포함) -router.get("/", async (req, res) => { - try { - const { search, startDate, endDate, limit, year, month } = req.query; - - // 검색어가 있으면 Meilisearch 사용 - if (search && search.trim()) { - const offset = parseInt(req.query.offset) || 0; - const pageLimit = parseInt(req.query.limit) || 100; - - // 첫 페이지 검색 시에만 검색어 저장 (bi-gram 학습) - if (offset === 0) { - saveSearchQuery(search.trim()).catch((err) => - console.error("검색어 저장 실패:", err.message) - ); - } - - // Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징) - const results = await searchSchedules(search.trim(), { - limit: 1000, // 내부적으로 1000개까지 검색 - }); - - // 페이징 적용 - const paginatedHits = results.hits.slice(offset, offset + pageLimit); - - return res.json({ - schedules: paginatedHits, - total: results.total, - offset: offset, - limit: pageLimit, - hasMore: offset + paginatedHits.length < results.total, - }); - } - - // 날짜 필터 및 제한 조건 구성 - let whereClause = "WHERE 1=1"; - const params = []; - - // 년/월 필터링 (월별 데이터 로딩용) - if (year && month) { - whereClause += " AND YEAR(s.date) = ? AND MONTH(s.date) = ?"; - params.push(parseInt(year), parseInt(month)); - } else if (year) { - whereClause += " AND YEAR(s.date) = ?"; - params.push(parseInt(year)); - } - - if (startDate) { - whereClause += " AND s.date >= ?"; - params.push(startDate); - } - if (endDate) { - whereClause += " AND s.date <= ?"; - params.push(endDate); - } - - // limit 파라미터 처리 - const limitClause = limit ? `LIMIT ${parseInt(limit)}` : ""; - - // 검색어 없으면 DB에서 전체 조회 - const [schedules] = await pool.query( - ` - SELECT - s.id, - s.title, - s.description, - s.date, - s.time, - s.category_id, - s.source_url, - s.source_name, - s.location_name, - c.name as category_name, - c.color as category_color, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - LEFT JOIN schedule_members sm ON s.id = sm.schedule_id - LEFT JOIN members m ON sm.member_id = m.id - ${whereClause} - GROUP BY s.id - ORDER BY s.date ASC, s.time ASC - ${limitClause} - `, - params - ); - - // 년/월 필터가 있으면 해당 월의 현재 멤버 생일을 가상 일정으로 추가 - if (year && month) { - const [birthdays] = await pool.query( - `SELECT id, name, name_en, birth_date, image_url - FROM members - WHERE is_former = 0 AND MONTH(birth_date) = ?`, - [parseInt(month)] - ); - - const birthdaySchedules = birthdays.map((member) => { - const birthDate = new Date(member.birth_date); - const birthdayThisYear = new Date( - parseInt(year), - birthDate.getMonth(), - birthDate.getDate() - ); - - return { - id: `birthday-${member.id}`, - title: `HAPPY ${member.name_en} DAY`, - description: null, - date: birthdayThisYear, - time: null, - category_id: 8, - source_url: null, - source_name: null, - location_name: null, - category_name: "생일", - category_color: "#f472b6", - member_names: member.name, - is_birthday: true, - member_image: member.image_url, - }; - }); - - // 일정과 생일을 합쳐서 날짜순 정렬 - const allSchedules = [...schedules, ...birthdaySchedules].sort( - (a, b) => new Date(a.date) - new Date(b.date) - ); - - return res.json(allSchedules); - } - - res.json(schedules); - } catch (error) { - console.error("일정 목록 조회 오류:", error); - res.status(500).json({ error: "일정 목록 조회 중 오류가 발생했습니다." }); - } -}); - -// 카테고리 목록 조회 -router.get("/categories", async (req, res) => { - try { - const [categories] = await pool.query(` - SELECT id, name, color, sort_order - FROM schedule_categories - ORDER BY sort_order ASC - `); - - res.json(categories); - } catch (error) { - console.error("카테고리 조회 오류:", error); - res.status(500).json({ error: "카테고리 조회 중 오류가 발생했습니다." }); - } -}); - -// 개별 일정 조회 -router.get("/:id", async (req, res) => { - try { - const { id } = req.params; - - const [schedules] = await pool.query( - ` - SELECT - s.*, - c.name as category_name, - c.color as category_color - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - WHERE s.id = ? - `, - [id] - ); - - if (schedules.length === 0) { - return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); - } - - const schedule = schedules[0]; - - // 이미지 조회 - const [images] = await pool.query( - `SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`, - [id] - ); - schedule.images = images.map((img) => img.image_url); - - // 멤버 조회 - const [members] = await pool.query( - `SELECT m.id, m.name FROM members m - JOIN schedule_members sm ON m.id = sm.member_id - WHERE sm.schedule_id = ? - ORDER BY m.id`, - [id] - ); - schedule.members = members; - - // 콘서트 카테고리(id=6)인 경우 같은 제목의 관련 일정들도 조회 - if (schedule.category_id === 6) { - const [relatedSchedules] = await pool.query( - ` - SELECT id, date, time - FROM schedules - WHERE title = ? AND category_id = 6 - ORDER BY date ASC, time ASC - `, - [schedule.title] - ); - schedule.related_dates = relatedSchedules; - } - - res.json(schedule); - } catch (error) { - console.error("일정 조회 오류:", error); - res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); - } -}); - -// Meilisearch 동기화 API -router.post("/sync-search", async (req, res) => { - try { - const { syncAllSchedules } = await import("../services/meilisearch.js"); - - // DB에서 모든 일정 조회 - const [schedules] = await pool.query(` - SELECT - s.id, - s.title, - s.description, - s.date, - s.time, - s.category_id, - s.source_url, - s.source_name, - c.name as category_name, - c.color as category_color, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - LEFT JOIN schedule_members sm ON s.id = sm.schedule_id - LEFT JOIN members m ON sm.member_id = m.id - GROUP BY s.id - `); - - const count = await syncAllSchedules(schedules); - res.json({ success: true, synced: count }); - } catch (error) { - console.error("Meilisearch 동기화 오류:", error); - res.status(500).json({ error: "동기화 중 오류가 발생했습니다." }); - } -}); - -// X 프로필 정보 조회 -router.get("/x-profile/:username", async (req, res) => { - try { - const { username } = req.params; - const profile = await getXProfile(username); - - if (!profile) { - return res.status(404).json({ error: "프로필을 찾을 수 없습니다." }); - } - - res.json(profile); - } catch (error) { - console.error("X 프로필 조회 오류:", error); - res.status(500).json({ error: "프로필 조회 중 오류가 발생했습니다." }); - } -}); - -export default router; diff --git a/backend/routes/stats.js b/backend/routes/stats.js deleted file mode 100644 index 30af305..0000000 --- a/backend/routes/stats.js +++ /dev/null @@ -1,28 +0,0 @@ -import express from "express"; -import pool from "../lib/db.js"; - -const router = express.Router(); - -// 통계 조회 (멤버 수, 앨범 수) -router.get("/", async (req, res) => { - try { - const [memberCount] = await pool.query( - "SELECT COUNT(*) as count FROM members" - ); - const [albumCount] = await pool.query( - "SELECT COUNT(*) as count FROM albums" - ); - - res.json({ - memberCount: memberCount[0].count, - albumCount: albumCount[0].count, - debutYear: 2018, - fandomName: "flover", - }); - } catch (error) { - console.error("통계 조회 오류:", error); - res.status(500).json({ error: "통계 정보를 가져오는데 실패했습니다." }); - } -}); - -export default router; diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index b994ba7..0000000 --- a/backend/server.js +++ /dev/null @@ -1,81 +0,0 @@ -import express from "express"; -import path from "path"; -import { fileURLToPath } from "url"; -import membersRouter from "./routes/members.js"; -import albumsRouter from "./routes/albums.js"; -import statsRouter from "./routes/stats.js"; -import adminRouter from "./routes/admin.js"; -import schedulesRouter from "./routes/schedules.js"; -import { initScheduler } from "./services/youtube-scheduler.js"; -import { initMeilisearch } from "./services/meilisearch.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const app = express(); -const PORT = process.env.PORT || 80; - -// JSON 파싱 -app.use(express.json()); - -// 정적 파일 서빙 (프론트엔드 빌드 결과물) -app.use(express.static(path.join(__dirname, "dist"))); - -// API 라우트 -app.get("/api/health", (req, res) => { - res.json({ status: "ok", timestamp: new Date().toISOString() }); -}); - -app.use("/api/members", membersRouter); -app.use("/api/albums", albumsRouter); -app.use("/api/stats", statsRouter); -app.use("/api/admin", adminRouter); -app.use("/api/schedules", schedulesRouter); -app.use("/api/schedule-categories", (req, res, next) => { - // /api/schedule-categories -> /api/schedules/categories로 리다이렉트 - req.url = "/categories"; - schedulesRouter(req, res, next); -}); - -// SPA 폴백 - 모든 요청을 index.html로 -app.get("*", (req, res) => { - res.sendFile(path.join(__dirname, "dist", "index.html")); -}); - -app.listen(PORT, async () => { - console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`); - - // Meilisearch 초기화 및 동기화 - try { - await initMeilisearch(); - console.log("🔍 Meilisearch 초기화 완료"); - - // 서버 시작 시 일정 데이터 자동 동기화 - const { syncAllSchedules } = await import("./services/meilisearch.js"); - const [schedules] = await ( - await import("./lib/db.js") - ).default.query(` - SELECT - s.id, s.title, s.description, s.date, s.time, s.category_id, s.source_url, s.source_name, - c.name as category_name, c.color as category_color, - GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ',') as member_names - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - LEFT JOIN schedule_members sm ON s.id = sm.schedule_id - LEFT JOIN members m ON sm.member_id = m.id - GROUP BY s.id - `); - const syncedCount = await syncAllSchedules(schedules); - console.log(`🔍 Meilisearch ${syncedCount}개 일정 동기화 완료`); - } catch (error) { - console.error("Meilisearch 초기화/동기화 오류:", error); - } - - // YouTube 봇 스케줄러 초기화 - try { - await initScheduler(); - console.log("📺 YouTube 봇 스케줄러 초기화 완료"); - } catch (error) { - console.error("YouTube 스케줄러 초기화 오류:", error); - } -}); diff --git a/backend/services/meilisearch-bot.js b/backend/services/meilisearch-bot.js deleted file mode 100644 index 0bec668..0000000 --- a/backend/services/meilisearch-bot.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Meilisearch 동기화 봇 서비스 - * 모든 일정을 Meilisearch에 동기화 - */ - -import pool from "../lib/db.js"; -import { addOrUpdateSchedule } from "./meilisearch.js"; - -/** - * 전체 일정 Meilisearch 동기화 - */ -export async function syncAllSchedules(botId) { - try { - const startTime = Date.now(); - - // 모든 일정 조회 - const [schedules] = await pool.query(` - SELECT s.id, s.title, s.description, s.date, s.time, - s.category_id, s.source_url, s.source_name, - c.name as category_name, c.color as category_color - FROM schedules s - LEFT JOIN schedule_categories c ON s.category_id = c.id - `); - - let synced = 0; - - for (const s of schedules) { - // 멤버 조회 - const [members] = await pool.query( - "SELECT m.id, m.name FROM schedule_members sm JOIN members m ON sm.member_id = m.id WHERE sm.schedule_id = ?", - [s.id] - ); - - // Meilisearch 동기화 - await addOrUpdateSchedule({ - id: s.id, - title: s.title, - description: s.description || "", - date: s.date, - time: s.time, - category_id: s.category_id, - category_name: s.category_name || "", - category_color: s.category_color || "", - source_name: s.source_name, - source_url: s.source_url, - members: members, - }); - - synced++; - } - - const elapsedMs = Date.now() - startTime; - const elapsedSec = (elapsedMs / 1000).toFixed(2); - - // 봇 상태 업데이트 (schedules_added = 동기화 수, last_added_count = 소요시간 ms) - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - schedules_added = ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [synced, elapsedMs, botId] - ); - - console.log(`[Meilisearch Bot] ${synced}개 동기화 완료 (${elapsedSec}초)`); - return { synced, elapsed: elapsedSec }; - } catch (error) { - // 오류 상태 업데이트 - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - status = 'error', - error_message = ? - WHERE id = ?`, - [error.message, botId] - ); - throw error; - } -} - -export default { - syncAllSchedules, -}; diff --git a/backend/services/meilisearch.js b/backend/services/meilisearch.js deleted file mode 100644 index 8308dc5..0000000 --- a/backend/services/meilisearch.js +++ /dev/null @@ -1,227 +0,0 @@ -import { MeiliSearch } from "meilisearch"; - -// Meilisearch 클라이언트 초기화 -const client = new MeiliSearch({ - host: "http://fromis9-meilisearch:7700", - apiKey: process.env.MEILI_MASTER_KEY, -}); - -const SCHEDULE_INDEX = "schedules"; - -/** - * 인덱스 초기화 및 설정 - */ -export async function initMeilisearch() { - try { - // 인덱스 생성 (이미 존재하면 무시) - await client.createIndex(SCHEDULE_INDEX, { primaryKey: "id" }); - - // 인덱스 설정 - const index = client.index(SCHEDULE_INDEX); - - // 검색 가능한 필드 설정 (순서가 우선순위 결정) - await index.updateSearchableAttributes([ - "title", - "member_names", - "description", - "source_name", - "category_name", - ]); - - // 필터링 가능한 필드 설정 - await index.updateFilterableAttributes(["category_id", "date"]); - - // 정렬 가능한 필드 설정 - await index.updateSortableAttributes(["date", "time"]); - - // 랭킹 규칙 설정 (동일 유사도일 때 최신 날짜 우선) - await index.updateRankingRules([ - "words", // 검색어 포함 개수 - "typo", // 오타 수 - "proximity", // 검색어 간 거리 - "attribute", // 필드 우선순위 - "exactness", // 정확도 - "date:desc", // 동일 유사도 시 최신 날짜 우선 - ]); - - // 오타 허용 설정 (typo tolerance) - await index.updateTypoTolerance({ - enabled: true, - minWordSizeForTypos: { - oneTypo: 2, - twoTypos: 4, - }, - }); - - // 페이징 설정 (기본 1000개 제한 해제) - await index.updatePagination({ - maxTotalHits: 10000, // 최대 10000개까지 조회 가능 - }); - - console.log("[Meilisearch] 인덱스 초기화 완료"); - } catch (error) { - console.error("[Meilisearch] 초기화 오류:", error.message); - } -} - -/** - * 일정 문서 추가/업데이트 - */ -export async function addOrUpdateSchedule(schedule) { - try { - const index = client.index(SCHEDULE_INDEX); - - // 멤버 이름을 쉼표로 구분하여 저장 - const memberNames = schedule.members - ? schedule.members.map((m) => m.name).join(",") - : ""; - - const document = { - id: schedule.id, - title: schedule.title, - description: schedule.description || "", - date: schedule.date, - time: schedule.time || "", - category_id: schedule.category_id, - category_name: schedule.category_name || "", - category_color: schedule.category_color || "", - source_name: schedule.source_name || "", - source_url: schedule.source_url || "", - member_names: memberNames, - }; - - await index.addDocuments([document]); - console.log(`[Meilisearch] 일정 추가/업데이트: ${schedule.id}`); - } catch (error) { - console.error("[Meilisearch] 문서 추가 오류:", error.message); - } -} - -/** - * 일정 문서 삭제 - */ -export async function deleteSchedule(scheduleId) { - try { - const index = client.index(SCHEDULE_INDEX); - await index.deleteDocument(scheduleId); - console.log(`[Meilisearch] 일정 삭제: ${scheduleId}`); - } catch (error) { - console.error("[Meilisearch] 문서 삭제 오류:", error.message); - } -} - -import Inko from "inko"; -const inko = new Inko(); - -/** - * 영문 자판으로 입력된 검색어인지 확인 (대부분 영문으로만 구성) - */ -function isEnglishKeyboard(text) { - const englishChars = text.match(/[a-zA-Z]/g) || []; - const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; - // 영문이 50% 이상이고 한글이 없으면 영문 자판 입력으로 간주 - return englishChars.length > 0 && koreanChars.length === 0; -} - -/** - * 일정 검색 (페이징 지원) - */ -export async function searchSchedules(query, options = {}) { - try { - const index = client.index(SCHEDULE_INDEX); - - const searchOptions = { - limit: options.limit || 1000, // 기본 1000개 (Meilisearch 최대) - offset: options.offset || 0, // 페이징용 offset - attributesToRetrieve: ["*"], - showRankingScore: true, // 유사도 점수 포함 - }; - - // 카테고리 필터 - if (options.categoryId) { - searchOptions.filter = `category_id = ${options.categoryId}`; - } - - // 정렬 지정 시에만 적용 (기본은 유사도순) - if (options.sort) { - searchOptions.sort = options.sort; - } - - // 원본 검색어로 검색 - const results = await index.search(query, searchOptions); - let allHits = [...results.hits]; - - // 영문 자판 입력인 경우 한글로 변환하여 추가 검색 - if (isEnglishKeyboard(query)) { - const koreanQuery = inko.en2ko(query); - if (koreanQuery !== query) { - const koreanResults = await index.search(koreanQuery, searchOptions); - // 중복 제거하며 병합 (id 기준) - const existingIds = new Set(allHits.map((h) => h.id)); - for (const hit of koreanResults.hits) { - if (!existingIds.has(hit.id)) { - allHits.push(hit); - existingIds.add(hit.id); - } - } - } - } - - // 유사도 0.5 미만인 결과 필터링 - const filteredHits = allHits.filter((hit) => hit._rankingScore >= 0.5); - - // 유사도 순으로 정렬 - filteredHits.sort( - (a, b) => (b._rankingScore || 0) - (a._rankingScore || 0) - ); - - // 페이징 정보 포함 반환 - return { - hits: filteredHits, - total: filteredHits.length, // 필터링 후 결과 수 - offset: searchOptions.offset, - limit: searchOptions.limit, - }; - } catch (error) { - console.error("[Meilisearch] 검색 오류:", error.message); - return { hits: [], total: 0, offset: 0, limit: 0 }; - } -} - -/** - * 모든 일정 동기화 (초기 데이터 로드용) - */ -export async function syncAllSchedules(schedules) { - try { - const index = client.index(SCHEDULE_INDEX); - - // 기존 문서 모두 삭제 - await index.deleteAllDocuments(); - - // 문서 변환 - const documents = schedules.map((schedule) => ({ - id: schedule.id, - title: schedule.title, - description: schedule.description || "", - date: schedule.date, - time: schedule.time || "", - category_id: schedule.category_id, - category_name: schedule.category_name || "", - category_color: schedule.category_color || "", - source_name: schedule.source_name || "", - source_url: schedule.source_url || "", - member_names: schedule.member_names || "", - })); - - // 일괄 추가 - await index.addDocuments(documents); - console.log(`[Meilisearch] ${documents.length}개 일정 동기화 완료`); - - return documents.length; - } catch (error) { - console.error("[Meilisearch] 동기화 오류:", error.message); - return 0; - } -} - -export { client }; diff --git a/backend/services/suggestions.js b/backend/services/suggestions.js deleted file mode 100644 index 258eeed..0000000 --- a/backend/services/suggestions.js +++ /dev/null @@ -1,248 +0,0 @@ -import pool from "../lib/db.js"; -import redis from "../lib/redis.js"; -import Inko from "inko"; -import { searchSchedules } from "./meilisearch.js"; - -const inko = new Inko(); - -// Redis 키 prefix -const SUGGESTION_PREFIX = "suggestions:"; -const CACHE_TTL = 86400; // 24시간 - -// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비) -// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출 -const MIN_COUNT_RATIO = 0.01; -// 최소 임계값 (데이터가 적을 때 오타 방지) -const MIN_COUNT_FLOOR = 10; - -/** - * 영문만 포함된 검색어인지 확인 - */ -function isEnglishOnly(text) { - const englishChars = text.match(/[a-zA-Z]/g) || []; - const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; - return englishChars.length > 0 && koreanChars.length === 0; -} - -/** - * 일정 검색 결과가 있는지 확인 (Meilisearch) - */ -async function hasScheduleResults(query) { - try { - const result = await searchSchedules(query, { limit: 1 }); - return result.hits.length > 0; - } catch (error) { - console.error("[SearchSuggestion] 검색 확인 오류:", error.message); - return false; - } -} - -/** - * 영어 입력을 분석하여 실제 영어인지 한글 오타인지 판단 - * 1. 영어로 일정 검색 → 결과 있으면 영어 - * 2. 한글 변환 후 일정 검색 → 결과 있으면 한글 - * 3. 둘 다 없으면 원본 유지 - */ -async function resolveEnglishInput(query) { - const koreanQuery = inko.en2ko(query); - - // 변환 결과가 같으면 변환 의미 없음 - if (koreanQuery === query) { - return { resolved: query, type: "english" }; - } - - // 1. 영어로 검색 - const hasEnglishResult = await hasScheduleResults(query); - if (hasEnglishResult) { - return { resolved: query, type: "english" }; - } - - // 2. 한글로 검색 - const hasKoreanResult = await hasScheduleResults(koreanQuery); - if (hasKoreanResult) { - return { resolved: koreanQuery, type: "korean_typo" }; - } - - // 3. 둘 다 없으면 원본 유지 - return { resolved: query, type: "unknown" }; -} - -/** - * 검색어 저장 (검색 실행 시 호출) - * - search_queries 테이블에 Unigram 저장 - * - word_pairs 테이블에 Bi-gram 저장 - * - Redis 캐시 업데이트 - * - 영어 입력 시 일정 검색으로 언어 판단 - */ -export async function saveSearchQuery(query) { - if (!query || query.trim().length === 0) return; - - let normalizedQuery = query.trim().toLowerCase(); - - // 영문만 있는 경우 일정 검색으로 언어 판단 - if (isEnglishOnly(normalizedQuery)) { - const { resolved, type } = await resolveEnglishInput(normalizedQuery); - if (type === "korean_typo") { - console.log( - `[SearchSuggestion] 한글 오타 감지: "${normalizedQuery}" → "${resolved}"` - ); - } - normalizedQuery = resolved; - } - - try { - // 1. Unigram 저장 (인기도) - await pool.query( - `INSERT INTO search_queries (query, count) - VALUES (?, 1) - ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`, - [normalizedQuery] - ); - - // 2. Bi-gram 저장 (다음 단어 예측) - const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0); - for (let i = 0; i < words.length - 1; i++) { - const word1 = words[i]; - const word2 = words[i + 1]; - - // DB 저장 - await pool.query( - `INSERT INTO word_pairs (word1, word2, count) - VALUES (?, ?, 1) - ON DUPLICATE KEY UPDATE count = count + 1`, - [word1, word2] - ); - - // Redis 캐시 업데이트 (Sorted Set) - await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2); - } - - console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`); - } catch (error) { - console.error("[SearchSuggestion] 검색어 저장 오류:", error.message); - } -} - -/** - * 추천 검색어 조회 - * - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram) - * - 그 외: prefix 매칭 (인기순) - * - 영어 입력 시: 일정 검색으로 영어/한글 판단 - */ -export async function getSuggestions(query, limit = 10) { - if (!query || query.trim().length === 0) { - return []; - } - - let searchQuery = query.toLowerCase(); - let koreanQuery = null; - - // 영문만 있는 경우, 한글 변환도 같이 검색 - if (isEnglishOnly(searchQuery)) { - const converted = inko.en2ko(searchQuery); - if (converted !== searchQuery) { - koreanQuery = converted; - } - } - - try { - const endsWithSpace = query.endsWith(" "); - const words = searchQuery - .trim() - .split(/\s+/) - .filter((w) => w.length > 0); - - if (endsWithSpace && words.length > 0) { - // 다음 단어 예측 (Bi-gram) - const lastWord = words[words.length - 1]; - return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit); - } else { - // prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다 - return await getPrefixSuggestions( - searchQuery.trim(), - koreanQuery?.trim(), - limit - ); - } - } catch (error) { - console.error("[SearchSuggestion] 추천 조회 오류:", error.message); - return []; - } -} - -/** - * 다음 단어 예측 (Bi-gram 기반) - * 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음 - */ -async function getNextWordSuggestions(lastWord, prefix, limit) { - try { - const [rows] = await pool.query( - `SELECT word2, count FROM word_pairs - WHERE word1 = ? - AND count >= GREATEST((SELECT MAX(count) * ? FROM word_pairs), ?) - ORDER BY count DESC - LIMIT ?`, - [lastWord, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] - ); - - // prefix + 다음 단어 조합으로 반환 - return rows.map((r) => `${prefix} ${r.word2}`); - } catch (error) { - console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message); - return []; - } -} - -/** - * Prefix 매칭 (인기순) - * @param {string} prefix - 원본 검색어 (영어 또는 한글) - * @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우) - * @param {number} limit - 결과 개수 - */ -async function getPrefixSuggestions(prefix, koreanPrefix, limit) { - try { - let rows; - - if (koreanPrefix) { - // 영어 원본과 한글 변환 둘 다 검색 - [rows] = await pool.query( - `SELECT query FROM search_queries - WHERE (query LIKE ? OR query LIKE ?) - AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?) - ORDER BY count DESC, last_searched_at DESC - LIMIT ?`, - [`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] - ); - } else { - // 단일 검색 - [rows] = await pool.query( - `SELECT query FROM search_queries - WHERE query LIKE ? - AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?) - ORDER BY count DESC, last_searched_at DESC - LIMIT ?`, - [`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] - ); - } - - return rows.map((r) => r.query); - } catch (error) { - console.error("[SearchSuggestion] Prefix 조회 오류:", error.message); - return []; - } -} - -/** - * Redis 캐시 초기화 (필요시) - */ -export async function clearSuggestionCache() { - try { - const keys = await redis.keys(`${SUGGESTION_PREFIX}*`); - if (keys.length > 0) { - await redis.del(...keys); - console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`); - } - } catch (error) { - console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message); - } -} diff --git a/backend/services/x-bot.js b/backend/services/x-bot.js deleted file mode 100644 index e4722bf..0000000 --- a/backend/services/x-bot.js +++ /dev/null @@ -1,687 +0,0 @@ -/** - * X 봇 서비스 - * - * - Nitter를 통해 @realfromis_9 트윗 수집 - * - 트윗을 schedules 테이블에 저장 - * - 유튜브 링크 감지 시 별도 일정 추가 - */ - -import pool from "../lib/db.js"; -import redis from "../lib/redis.js"; -import { addOrUpdateSchedule } from "./meilisearch.js"; -import { - toKST, - formatDate, - formatTime, - parseNitterDateTime, -} from "../lib/date.js"; - -// X 프로필 캐시 키 prefix -const X_PROFILE_CACHE_PREFIX = "x_profile:"; - -// YouTube API 키 -const YOUTUBE_API_KEY = - process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; - -// X 카테고리 ID -const X_CATEGORY_ID = 3; - -// 유튜브 카테고리 ID -const YOUTUBE_CATEGORY_ID = 2; - -/** - * 트윗 텍스트에서 첫 문단 추출 (title용) - */ -export function extractTitle(text) { - if (!text) return ""; - - // 빈 줄(\n\n)로 분리하여 첫 문단 추출 - const paragraphs = text.split(/\n\n+/); - const firstParagraph = paragraphs[0]?.trim() || ""; - - return firstParagraph; -} - -/** - * 텍스트에서 유튜브 videoId 추출 - */ -export function extractYoutubeVideoIds(text) { - if (!text) return []; - - const videoIds = []; - - // youtu.be/{videoId} 형식 - const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g); - if (shortMatches) { - shortMatches.forEach((m) => { - const id = m.replace("youtu.be/", ""); - if (id && id.length === 11) videoIds.push(id); - }); - } - - // youtube.com/watch?v={videoId} 형식 - const watchMatches = text.match( - /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g - ); - if (watchMatches) { - watchMatches.forEach((m) => { - const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1]; - if (id) videoIds.push(id); - }); - } - - // youtube.com/shorts/{videoId} 형식 - const shortsMatches = text.match( - /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g - ); - if (shortsMatches) { - shortsMatches.forEach((m) => { - const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1]; - if (id) videoIds.push(id); - }); - } - - return [...new Set(videoIds)]; -} - -/** - * 관리 중인 채널 ID 목록 조회 - */ -export async function getManagedChannelIds() { - const [configs] = await pool.query( - "SELECT channel_id FROM bot_youtube_config" - ); - return configs.map((c) => c.channel_id); -} - -/** - * YouTube API로 영상 정보 조회 - */ -async function fetchVideoInfo(videoId) { - try { - const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`; - const response = await fetch(url); - const data = await response.json(); - - if (!data.items || data.items.length === 0) { - return null; - } - - const video = data.items[0]; - const snippet = video.snippet; - const duration = video.contentDetails?.duration || ""; - - // duration 파싱 - const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); - let seconds = 0; - if (durationMatch) { - seconds = - parseInt(durationMatch[1] || 0) * 3600 + - parseInt(durationMatch[2] || 0) * 60 + - parseInt(durationMatch[3] || 0); - } - - const isShorts = seconds > 0 && seconds <= 60; - - return { - videoId, - title: snippet.title, - description: snippet.description || "", - channelId: snippet.channelId, - channelTitle: snippet.channelTitle, - publishedAt: new Date(snippet.publishedAt), - isShorts, - videoUrl: isShorts - ? `https://www.youtube.com/shorts/${videoId}` - : `https://www.youtube.com/watch?v=${videoId}`, - }; - } catch (error) { - console.error(`영상 정보 조회 오류 (${videoId}):`, error.message); - return null; - } -} - -/** - * Nitter HTML에서 프로필 정보 추출 - */ -function extractProfileFromHtml(html) { - const profile = { - displayName: null, - avatarUrl: null, - }; - - // Display name 추출: 이름 - const nameMatch = html.match( - /class="profile-card-fullname"[^>]*title="([^"]+)"/ - ); - if (nameMatch) { - profile.displayName = nameMatch[1].trim(); - } - - // Avatar URL 추출: - const avatarMatch = html.match( - /class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/ - ); - if (avatarMatch) { - profile.avatarUrl = avatarMatch[1]; - } - - return profile; -} - -/** - * X 프로필 정보 캐시에 저장 - */ -async function cacheXProfile(username, profile, nitterUrl) { - try { - // Nitter 프록시 URL에서 원본 Twitter 이미지 URL 추출 - let avatarUrl = profile.avatarUrl; - if (avatarUrl) { - // /pic/https%3A%2F%2Fpbs.twimg.com%2F... 형식에서 원본 URL 추출 - const encodedMatch = avatarUrl.match(/\/pic\/(.+)/); - if (encodedMatch) { - avatarUrl = decodeURIComponent(encodedMatch[1]); - } else if (avatarUrl.startsWith("/")) { - // 상대 경로인 경우 Nitter URL 추가 - avatarUrl = `${nitterUrl}${avatarUrl}`; - } - } - - const data = { - username, - displayName: profile.displayName, - avatarUrl, - updatedAt: new Date().toISOString(), - }; - - // 7일간 캐시 (604800초) - await redis.setex( - `${X_PROFILE_CACHE_PREFIX}${username}`, - 604800, - JSON.stringify(data) - ); - - console.log(`[X 프로필] ${username} 캐시 저장 완료`); - } catch (error) { - console.error(`[X 프로필] 캐시 저장 실패:`, error.message); - } -} - -/** - * X 프로필 정보 조회 - */ -export async function getXProfile(username) { - try { - const cached = await redis.get(`${X_PROFILE_CACHE_PREFIX}${username}`); - if (cached) { - return JSON.parse(cached); - } - return null; - } catch (error) { - console.error(`[X 프로필] 캐시 조회 실패:`, error.message); - return null; - } -} - -/** - * Nitter에서 트윗 수집 (첫 페이지만) - */ -async function fetchTweetsFromNitter(nitterUrl, username) { - const url = `${nitterUrl}/${username}`; - - const response = await fetch(url); - const html = await response.text(); - - // 프로필 정보 추출 및 캐싱 - const profile = extractProfileFromHtml(html); - if (profile.displayName || profile.avatarUrl) { - await cacheXProfile(username, profile, nitterUrl); - } - - const tweets = []; - const tweetContainers = html.split('class="timeline-item '); - - for (let i = 1; i < tweetContainers.length; i++) { - const container = tweetContainers[i]; - const tweet = {}; - - // 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인 - tweet.isPinned = container.includes('class="pinned"'); - - // 리트윗 체크 - tweet.isRetweet = container.includes('class="retweet-header"'); - - // 트윗 ID 추출 - const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); - tweet.id = linkMatch ? linkMatch[1] : null; - - // 시간 추출 - const timeMatch = container.match( - /]*>]*title="([^"]+)"/ - ); - tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; - - // 텍스트 내용 추출 - const contentMatch = container.match( - /
]*>([\s\S]*?)<\/div>/ - ); - if (contentMatch) { - tweet.text = contentMatch[1] - .replace(//g, "\n") - .replace(/]*>([^<]*)<\/a>/g, "$1") - .replace(/<[^>]+>/g, "") - .trim(); - } - - // URL 생성 - tweet.url = tweet.id - ? `https://x.com/${username}/status/${tweet.id}` - : null; - - if (tweet.id && !tweet.isRetweet && !tweet.isPinned) { - tweets.push(tweet); - } - } - - return tweets; -} - -/** - * Nitter에서 전체 트윗 수집 (페이지네이션) - */ -async function fetchAllTweetsFromNitter(nitterUrl, username) { - const allTweets = []; - let cursor = null; - let pageNum = 1; - let consecutiveEmpty = 0; - const DELAY_MS = 1000; - - while (true) { - const url = cursor - ? `${nitterUrl}/${username}?cursor=${cursor}` - : `${nitterUrl}/${username}`; - - console.log(`[페이지 ${pageNum}] 스크래핑 중...`); - - try { - const response = await fetch(url); - const html = await response.text(); - - const tweets = []; - const tweetContainers = html.split('class="timeline-item '); - - for (let i = 1; i < tweetContainers.length; i++) { - const container = tweetContainers[i]; - const tweet = {}; - - // 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인 - tweet.isPinned = container.includes('class="pinned"'); - tweet.isRetweet = container.includes('class="retweet-header"'); - - const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); - tweet.id = linkMatch ? linkMatch[1] : null; - - const timeMatch = container.match( - /]*>]*title="([^"]+)"/ - ); - tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; - - const contentMatch = container.match( - /
]*>([\s\S]*?)<\/div>/ - ); - if (contentMatch) { - tweet.text = contentMatch[1] - .replace(//g, "\n") - .replace(/]*>([^<]*)<\/a>/g, "$1") - .replace(/<[^>]+>/g, "") - .trim(); - } - - tweet.url = tweet.id - ? `https://x.com/${username}/status/${tweet.id}` - : null; - - if (tweet.id && !tweet.isRetweet && !tweet.isPinned) { - tweets.push(tweet); - } - } - - if (tweets.length === 0) { - consecutiveEmpty++; - console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`); - if (consecutiveEmpty >= 3) break; - } else { - consecutiveEmpty = 0; - allTweets.push(...tweets); - console.log(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`); - } - - // 다음 페이지 cursor 추출 - const cursorMatch = html.match( - /class="show-more"[^>]*>\s* setTimeout(r, DELAY_MS)); - } catch (error) { - console.error(` -> 오류: ${error.message}`); - consecutiveEmpty++; - if (consecutiveEmpty >= 5) break; - await new Promise((r) => setTimeout(r, DELAY_MS * 3)); - } - } - - return allTweets; -} - -/** - * 트윗을 일정으로 저장 - */ -async function createScheduleFromTweet(tweet) { - // source_url로 중복 체크 - const [existing] = await pool.query( - "SELECT id FROM schedules WHERE source_url = ?", - [tweet.url] - ); - - if (existing.length > 0) { - return null; // 이미 존재 - } - - const kstDate = toKST(tweet.time); - const date = formatDate(kstDate); - const time = formatTime(kstDate); - const title = extractTitle(tweet.text); - const description = tweet.text; - - // 일정 생성 - const [result] = await pool.query( - `INSERT INTO schedules (title, description, date, time, category_id, source_url, source_name) - VALUES (?, ?, ?, ?, ?, ?, NULL)`, - [title, description, date, time, X_CATEGORY_ID, tweet.url] - ); - - const scheduleId = result.insertId; - - // Meilisearch 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [X_CATEGORY_ID] - ); - await addOrUpdateSchedule({ - id: scheduleId, - title, - description, - date, - time, - category_id: X_CATEGORY_ID, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: null, - source_url: tweet.url, - members: [], - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - return scheduleId; -} - -/** - * 유튜브 영상을 일정으로 저장 - */ -async function createScheduleFromYoutube(video) { - // source_url로 중복 체크 - const [existing] = await pool.query( - "SELECT id FROM schedules WHERE source_url = ?", - [video.videoUrl] - ); - - if (existing.length > 0) { - return null; // 이미 존재 - } - - const kstDate = toKST(video.publishedAt); - const date = formatDate(kstDate); - const time = formatTime(kstDate); - - // 일정 생성 (source_name에 채널명 저장) - const [result] = await pool.query( - `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - video.title, - date, - time, - YOUTUBE_CATEGORY_ID, - video.videoUrl, - video.channelTitle || null, - ] - ); - - const scheduleId = result.insertId; - - // Meilisearch 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [YOUTUBE_CATEGORY_ID] - ); - await addOrUpdateSchedule({ - id: scheduleId, - title: video.title, - description: "", - date, - time, - category_id: YOUTUBE_CATEGORY_ID, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: video.channelTitle || null, - source_url: video.videoUrl, - members: [], - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - return scheduleId; -} - -/** - * 새 트윗 동기화 (첫 페이지만 - 1분 간격 실행용) - */ -export async function syncNewTweets(botId) { - try { - // 봇 정보 조회 - const [bots] = await pool.query( - `SELECT b.*, c.username, c.nitter_url - FROM bots b - LEFT JOIN bot_x_config c ON b.id = c.bot_id - WHERE b.id = ?`, - [botId] - ); - - if (bots.length === 0) { - throw new Error("봇을 찾을 수 없습니다."); - } - - const bot = bots[0]; - - if (!bot.username) { - throw new Error("Username이 설정되지 않았습니다."); - } - - const nitterUrl = bot.nitter_url || "http://nitter:8080"; - - // 관리 중인 채널 목록 조회 - const managedChannelIds = await getManagedChannelIds(); - - // Nitter에서 트윗 수집 (첫 페이지만) - const tweets = await fetchTweetsFromNitter(nitterUrl, bot.username); - - let addedCount = 0; - let ytAddedCount = 0; - - for (const tweet of tweets) { - // 트윗 저장 - const scheduleId = await createScheduleFromTweet(tweet); - if (scheduleId) addedCount++; - - // 유튜브 링크 처리 - const videoIds = extractYoutubeVideoIds(tweet.text); - for (const videoId of videoIds) { - const video = await fetchVideoInfo(videoId); - if (!video) continue; - - // 관리 중인 채널이면 스킵 - if (managedChannelIds.includes(video.channelId)) continue; - - // 유튜브 일정 저장 - const ytScheduleId = await createScheduleFromYoutube(video); - if (ytScheduleId) ytAddedCount++; - } - } - - // 봇 상태 업데이트 - // 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지) - const totalAdded = addedCount + ytAddedCount; - if (totalAdded > 0) { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [totalAdded, totalAdded, botId] - ); - } else { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - error_message = NULL - WHERE id = ?`, - [botId] - ); - } - - return { addedCount, ytAddedCount, total: tweets.length }; - } catch (error) { - // 오류 상태 업데이트 - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - status = 'error', - error_message = ? - WHERE id = ?`, - [error.message, botId] - ); - throw error; - } -} - -/** - * 전체 트윗 동기화 (전체 페이지 - 초기화용) - */ -export async function syncAllTweets(botId) { - try { - // 봇 정보 조회 - const [bots] = await pool.query( - `SELECT b.*, c.username, c.nitter_url - FROM bots b - LEFT JOIN bot_x_config c ON b.id = c.bot_id - WHERE b.id = ?`, - [botId] - ); - - if (bots.length === 0) { - throw new Error("봇을 찾을 수 없습니다."); - } - - const bot = bots[0]; - - if (!bot.username) { - throw new Error("Username이 설정되지 않았습니다."); - } - - const nitterUrl = bot.nitter_url || "http://nitter:8080"; - - // 관리 중인 채널 목록 조회 - const managedChannelIds = await getManagedChannelIds(); - - // Nitter에서 전체 트윗 수집 - const tweets = await fetchAllTweetsFromNitter(nitterUrl, bot.username); - - let addedCount = 0; - let ytAddedCount = 0; - - for (const tweet of tweets) { - // 트윗 저장 - const scheduleId = await createScheduleFromTweet(tweet); - if (scheduleId) addedCount++; - - // 유튜브 링크 처리 - const videoIds = extractYoutubeVideoIds(tweet.text); - for (const videoId of videoIds) { - const video = await fetchVideoInfo(videoId); - if (!video) continue; - - // 관리 중인 채널이면 스킵 - if (managedChannelIds.includes(video.channelId)) continue; - - // 유튜브 일정 저장 - const ytScheduleId = await createScheduleFromYoutube(video); - if (ytScheduleId) ytAddedCount++; - } - } - - // 봇 상태 업데이트 - // 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지) - const totalAdded = addedCount + ytAddedCount; - if (totalAdded > 0) { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [totalAdded, totalAdded, botId] - ); - } else { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - error_message = NULL - WHERE id = ?`, - [botId] - ); - } - - return { addedCount, ytAddedCount, total: tweets.length }; - } catch (error) { - await pool.query( - `UPDATE bots SET - status = 'error', - error_message = ? - WHERE id = ?`, - [error.message, botId] - ); - throw error; - } -} - -export default { - syncNewTweets, - syncAllTweets, - extractTitle, - extractYoutubeVideoIds, -}; diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js deleted file mode 100644 index 702ef85..0000000 --- a/backend/services/youtube-bot.js +++ /dev/null @@ -1,648 +0,0 @@ -import Parser from "rss-parser"; -import pool from "../lib/db.js"; -import { addOrUpdateSchedule } from "./meilisearch.js"; -import { toKST, formatDate, formatTime } from "../lib/date.js"; - -// YouTube API 키 -const YOUTUBE_API_KEY = - process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; - -// 봇별 커스텀 설정 (DB 대신 코드에서 관리) -// botId를 키로 사용 -const BOT_CUSTOM_CONFIG = { - // MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출 - 3: { - titleFilter: "성수기", - defaultMemberId: 7, // 이채영 - extractMembersFromDesc: true, - }, -}; - -/** - * 봇 커스텀 설정 조회 - */ -function getBotCustomConfig(botId) { - return ( - BOT_CUSTOM_CONFIG[botId] || { - titleFilter: null, - defaultMemberId: null, - extractMembersFromDesc: false, - } - ); -} - -// RSS 파서 설정 (media:description 포함) -const rssParser = new Parser({ - customFields: { - item: [ - ["yt:videoId", "videoId"], - ["yt:channelId", "channelId"], - ["media:group", "mediaGroup"], - ], - }, -}); - -/** - * '유튜브' 카테고리 ID 조회 (없으면 생성) - */ -export async function getYoutubeCategory() { - const [rows] = await pool.query( - "SELECT id FROM schedule_categories WHERE name = '유튜브'" - ); - - if (rows.length > 0) { - return rows[0].id; - } - - // 없으면 생성 - const [result] = await pool.query( - "INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)" - ); - return result.insertId; -} - -/** - * 영상 URL에서 유형 판별 (video/shorts) - */ -export function getVideoType(url) { - if (url.includes("/shorts/")) { - return "shorts"; - } - return "video"; -} - -/** - * 영상 URL 생성 - */ -export function getVideoUrl(videoId, videoType) { - if (videoType === "shorts") { - return `https://www.youtube.com/shorts/${videoId}`; - } - return `https://www.youtube.com/watch?v=${videoId}`; -} - -/** - * RSS 피드 파싱하여 영상 목록 반환 - */ -export async function parseRSSFeed(rssUrl) { - try { - const feed = await rssParser.parseURL(rssUrl); - - return feed.items.map((item) => { - const videoId = item.videoId; - const link = item.link || ""; - const videoType = getVideoType(link); - const publishedAt = toKST(new Date(item.pubDate)); - - // media:group에서 description 추출 - let description = ""; - if (item.mediaGroup && item.mediaGroup["media:description"]) { - description = item.mediaGroup["media:description"][0] || ""; - } - - return { - videoId, - title: item.title, - description, - publishedAt, - date: formatDate(publishedAt), - time: formatTime(publishedAt), - videoUrl: link || getVideoUrl(videoId, videoType), - videoType, - }; - }); - } catch (error) { - console.error("RSS 파싱 오류:", error); - throw error; - } -} - -/** - * ISO 8601 duration (PT1M30S) → 초 변환 - */ -function parseDuration(duration) { - const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); - if (!match) return 0; - - const hours = parseInt(match[1] || 0); - const minutes = parseInt(match[2] || 0); - const seconds = parseInt(match[3] || 0); - - return hours * 3600 + minutes * 60 + seconds; -} - -/** - * YouTube API로 최근 N개 영상 수집 (정기 동기화용) - * @param {string} channelId - 채널 ID - * @param {number} maxResults - 조회할 영상 수 (기본 10) - */ -export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) { - const videos = []; - - try { - // 채널의 업로드 플레이리스트 ID 조회 - const channelResponse = await fetch( - `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` - ); - const channelData = await channelResponse.json(); - - if (!channelData.items || channelData.items.length === 0) { - throw new Error("채널을 찾을 수 없습니다."); - } - - const uploadsPlaylistId = - channelData.items[0].contentDetails.relatedPlaylists.uploads; - - // 플레이리스트 아이템 조회 (최근 N개만) - const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`; - const response = await fetch(url); - const data = await response.json(); - - if (data.error) { - throw new Error(data.error.message); - } - - // 영상 ID 목록 추출 - const videoIds = data.items.map((item) => item.snippet.resourceId.videoId); - - // videos API로 duration 조회 (Shorts 판별용) - const videosResponse = await fetch( - `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( - "," - )}&key=${YOUTUBE_API_KEY}` - ); - const videosData = await videosResponse.json(); - - // duration으로 Shorts 판별 맵 생성 - const durationMap = {}; - if (videosData.items) { - for (const v of videosData.items) { - const duration = v.contentDetails.duration; - const seconds = parseDuration(duration); - durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; - } - } - - for (const item of data.items) { - const snippet = item.snippet; - const videoId = snippet.resourceId.videoId; - const publishedAt = toKST(new Date(snippet.publishedAt)); - const videoType = durationMap[videoId] || "video"; - - videos.push({ - videoId, - title: snippet.title, - description: snippet.description || "", - publishedAt, - date: formatDate(publishedAt), - time: formatTime(publishedAt), - videoUrl: getVideoUrl(videoId, videoType), - videoType, - }); - } - - return videos; - } catch (error) { - console.error("YouTube API 오류:", error); - throw error; - } -} - -/** - * YouTube API로 전체 영상 수집 (초기 동기화용) - * Shorts 판별: duration이 60초 이하이면 Shorts - */ -export async function fetchAllVideosFromAPI(channelId) { - const videos = []; - let pageToken = ""; - - try { - // 채널의 업로드 플레이리스트 ID 조회 - const channelResponse = await fetch( - `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` - ); - const channelData = await channelResponse.json(); - - if (!channelData.items || channelData.items.length === 0) { - throw new Error("채널을 찾을 수 없습니다."); - } - - const uploadsPlaylistId = - channelData.items[0].contentDetails.relatedPlaylists.uploads; - - // 플레이리스트 아이템 조회 (페이징) - do { - const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${ - pageToken ? `&pageToken=${pageToken}` : "" - }`; - - const response = await fetch(url); - const data = await response.json(); - - if (data.error) { - throw new Error(data.error.message); - } - - // 영상 ID 목록 추출 - const videoIds = data.items.map( - (item) => item.snippet.resourceId.videoId - ); - - // videos API로 duration 조회 (50개씩 배치) - const videosResponse = await fetch( - `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( - "," - )}&key=${YOUTUBE_API_KEY}` - ); - const videosData = await videosResponse.json(); - - // duration으로 Shorts 판별 맵 생성 - const durationMap = {}; - if (videosData.items) { - for (const v of videosData.items) { - const duration = v.contentDetails.duration; - const seconds = parseDuration(duration); - durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; - } - } - - for (const item of data.items) { - const snippet = item.snippet; - const videoId = snippet.resourceId.videoId; - const publishedAt = toKST(new Date(snippet.publishedAt)); - const videoType = durationMap[videoId] || "video"; - - videos.push({ - videoId, - title: snippet.title, - description: snippet.description || "", - publishedAt, - date: formatDate(publishedAt), - time: formatTime(publishedAt), - videoUrl: getVideoUrl(videoId, videoType), - videoType, - }); - } - - pageToken = data.nextPageToken || ""; - } while (pageToken); - - // 과거순 정렬 (오래된 영상부터 추가) - videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt)); - - return videos; - } catch (error) { - console.error("YouTube API 오류:", error); - throw error; - } -} - -/** - * 영상을 일정으로 추가 (source_url로 중복 체크) - * @param {Object} video - 영상 정보 - * @param {number} categoryId - 카테고리 ID - * @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택) - * @param {string} sourceName - 출처 이름 (선택) - */ -export async function createScheduleFromVideo( - video, - categoryId, - memberIds = [], - sourceName = null -) { - try { - // source_url로 중복 체크 - const [existing] = await pool.query( - "SELECT id FROM schedules WHERE source_url = ?", - [video.videoUrl] - ); - - if (existing.length > 0) { - return null; // 이미 존재 - } - - // 일정 생성 - const [result] = await pool.query( - `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - video.title, - video.date, - video.time, - categoryId, - video.videoUrl, - sourceName, - ] - ); - - const scheduleId = result.insertId; - - // 멤버 연결 - if (memberIds.length > 0) { - const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거 - const memberValues = uniqueMemberIds.map((memberId) => [ - scheduleId, - memberId, - ]); - await pool.query( - `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, - [memberValues] - ); - } - - // Meilisearch에 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [categoryId] - ); - const [memberInfo] = await pool.query( - "SELECT id, name FROM members WHERE id IN (?)", - [memberIds.length > 0 ? [...new Set(memberIds)] : [0]] - ); - await addOrUpdateSchedule({ - id: scheduleId, - title: video.title, - description: "", - date: video.date, - time: video.time, - category_id: categoryId, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: sourceName, - source_url: video.videoUrl, - members: memberInfo, - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - return scheduleId; - } catch (error) { - console.error("일정 생성 오류:", error); - throw error; - } -} - -/** - * 멤버 이름 목록 조회 - */ -async function getMemberNameMap() { - const [members] = await pool.query("SELECT id, name FROM members"); - const nameMap = {}; - for (const m of members) { - nameMap[m.name] = m.id; - } - return nameMap; -} - -/** - * description에서 멤버 이름 추출 - */ -function extractMemberIdsFromDescription(description, memberNameMap) { - if (!description) return []; - - const memberIds = []; - for (const [name, id] of Object.entries(memberNameMap)) { - if (description.includes(name)) { - memberIds.push(id); - } - } - return memberIds; -} - -/** - * 봇의 새 영상 동기화 (YouTube API 기반) - */ -export async function syncNewVideos(botId) { - try { - // 봇 정보 조회 (bot_youtube_config 조인) - const [bots] = await pool.query( - ` - SELECT b.*, c.channel_id - FROM bots b - LEFT JOIN bot_youtube_config c ON b.id = c.bot_id - WHERE b.id = ? - `, - [botId] - ); - - if (bots.length === 0) { - throw new Error("봇을 찾을 수 없습니다."); - } - - const bot = bots[0]; - - if (!bot.channel_id) { - throw new Error("Channel ID가 설정되지 않았습니다."); - } - - // 봇별 커스텀 설정 조회 - const customConfig = getBotCustomConfig(botId); - - const categoryId = await getYoutubeCategory(); - - // YouTube API로 최근 10개 영상 조회 - const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10); - let addedCount = 0; - - // 멤버 추출을 위한 이름 맵 조회 (필요 시) - let memberNameMap = null; - if (customConfig.extractMembersFromDesc) { - memberNameMap = await getMemberNameMap(); - } - - for (const video of videos) { - // 제목 필터 적용 (설정된 경우) - if ( - customConfig.titleFilter && - !video.title.includes(customConfig.titleFilter) - ) { - continue; // 필터에 맞지 않으면 스킵 - } - - // 멤버 ID 수집 - const memberIds = []; - - // 기본 멤버 추가 - if (customConfig.defaultMemberId) { - memberIds.push(customConfig.defaultMemberId); - } - - // description에서 멤버 추출 (설정된 경우) - if (customConfig.extractMembersFromDesc && memberNameMap) { - const extractedIds = extractMemberIdsFromDescription( - video.description, - memberNameMap - ); - memberIds.push(...extractedIds); - } - - const scheduleId = await createScheduleFromVideo( - video, - categoryId, - memberIds, - bot.name - ); - if (scheduleId) { - addedCount++; - } - } - - // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) - // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) - if (addedCount > 0) { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [addedCount, addedCount, botId] - ); - } else { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - error_message = NULL - WHERE id = ?`, - [botId] - ); - } - - return { addedCount, total: videos.length }; - } catch (error) { - // 오류 상태 업데이트 - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - status = 'error', - error_message = ? - WHERE id = ?`, - [error.message, botId] - ); - throw error; - } -} - -/** - * 전체 영상 동기화 (API 기반, 초기화용) - */ -export async function syncAllVideos(botId) { - try { - // 봇 정보 조회 (bot_youtube_config 조인) - const [bots] = await pool.query( - ` - SELECT b.*, c.channel_id - FROM bots b - LEFT JOIN bot_youtube_config c ON b.id = c.bot_id - WHERE b.id = ? - `, - [botId] - ); - - if (bots.length === 0) { - throw new Error("봇을 찾을 수 없습니다."); - } - - const bot = bots[0]; - - if (!bot.channel_id) { - throw new Error("Channel ID가 설정되지 않았습니다."); - } - - // 봇별 커스텀 설정 조회 - const customConfig = getBotCustomConfig(botId); - - const categoryId = await getYoutubeCategory(); - - // API로 전체 영상 수집 - const videos = await fetchAllVideosFromAPI(bot.channel_id); - let addedCount = 0; - - // 멤버 추출을 위한 이름 맵 조회 (필요 시) - let memberNameMap = null; - if (customConfig.extractMembersFromDesc) { - memberNameMap = await getMemberNameMap(); - } - - for (const video of videos) { - // 제목 필터 적용 (설정된 경우) - if ( - customConfig.titleFilter && - !video.title.includes(customConfig.titleFilter) - ) { - continue; // 필터에 맞지 않으면 스킵 - } - - // 멤버 ID 수집 - const memberIds = []; - - // 기본 멤버 추가 - if (customConfig.defaultMemberId) { - memberIds.push(customConfig.defaultMemberId); - } - - // description에서 멤버 추출 (설정된 경우) - if (customConfig.extractMembersFromDesc && memberNameMap) { - const extractedIds = extractMemberIdsFromDescription( - video.description, - memberNameMap - ); - memberIds.push(...extractedIds); - } - - const scheduleId = await createScheduleFromVideo( - video, - categoryId, - memberIds, - bot.name - ); - if (scheduleId) { - addedCount++; - } - } - - // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) - // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) - if (addedCount > 0) { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [addedCount, addedCount, botId] - ); - } else { - await pool.query( - `UPDATE bots SET - last_check_at = NOW(), - error_message = NULL - WHERE id = ?`, - [botId] - ); - } - - return { addedCount, total: videos.length }; - } catch (error) { - await pool.query( - `UPDATE bots SET - status = 'error', - error_message = ? - WHERE id = ?`, - [error.message, botId] - ); - throw error; - } -} - -export default { - parseRSSFeed, - fetchAllVideosFromAPI, - syncNewVideos, - syncAllVideos, - getYoutubeCategory, -}; diff --git a/backend/services/youtube-scheduler.js b/backend/services/youtube-scheduler.js deleted file mode 100644 index 3b6db07..0000000 --- a/backend/services/youtube-scheduler.js +++ /dev/null @@ -1,201 +0,0 @@ -import cron from "node-cron"; -import pool from "../lib/db.js"; -import { syncNewVideos } from "./youtube-bot.js"; -import { syncNewTweets } from "./x-bot.js"; -import { syncAllSchedules } from "./meilisearch-bot.js"; - -// 봇별 스케줄러 인스턴스 저장 -const schedulers = new Map(); - -/** - * 봇 타입에 따라 적절한 동기화 함수 호출 - */ -async function syncBot(botId) { - const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [ - botId, - ]); - if (bots.length === 0) throw new Error("봇을 찾을 수 없습니다."); - - const botType = bots[0].type; - - if (botType === "youtube") { - return await syncNewVideos(botId); - } else if (botType === "x") { - return await syncNewTweets(botId); - } else if (botType === "meilisearch") { - return await syncAllSchedules(botId); - } else { - throw new Error(`지원하지 않는 봇 타입: ${botType}`); - } -} - -/** - * 봇이 메모리에서 실행 중인지 확인 - */ -export function isBotRunning(botId) { - const id = parseInt(botId); - return schedulers.has(id); -} - -/** - * 개별 봇 스케줄 등록 - */ -export function registerBot(botId, intervalMinutes = 2, cronExpression = null) { - const id = parseInt(botId); - // 기존 스케줄이 있으면 제거 - unregisterBot(id); - - // cron 표현식: 지정된 표현식 사용, 없으면 기본값 생성 - const expression = cronExpression || `1-59/${intervalMinutes} * * * *`; - - const task = cron.schedule(expression, async () => { - console.log(`[Bot ${id}] 동기화 시작...`); - try { - const result = await syncBot(id); - console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`); - } catch (error) { - console.error(`[Bot ${id}] 동기화 오류:`, error.message); - } - }); - - schedulers.set(id, task); - console.log(`[Bot ${id}] 스케줄 등록됨 (cron: ${expression})`); -} - -/** - * 개별 봇 스케줄 해제 - */ -export function unregisterBot(botId) { - const id = parseInt(botId); - if (schedulers.has(id)) { - schedulers.get(id).stop(); - schedulers.delete(id); - console.log(`[Bot ${id}] 스케줄 해제됨`); - } -} - -/** - * 10초 간격으로 메모리 상태와 DB status 동기화 - */ -async function syncBotStatuses() { - try { - const [bots] = await pool.query("SELECT id, status FROM bots"); - - for (const bot of bots) { - const botId = parseInt(bot.id); - const isRunningInMemory = schedulers.has(botId); - const isRunningInDB = bot.status === "running"; - - // 메모리에 없는데 DB가 running이면 → 서버 크래시 등으로 불일치 - // 이 경우 DB를 stopped로 변경하는 대신, 메모리에 봇을 다시 등록 - if (!isRunningInMemory && isRunningInDB) { - console.log(`[Scheduler] Bot ${botId} 메모리에 없음, 재등록 시도...`); - try { - const [botInfo] = await pool.query( - "SELECT check_interval, cron_expression FROM bots WHERE id = ?", - [botId] - ); - if (botInfo.length > 0) { - const { check_interval, cron_expression } = botInfo[0]; - // 직접 registerBot 함수 호출 (import 순환 방지를 위해 내부 로직 사용) - const expression = - cron_expression || `1-59/${check_interval} * * * *`; - const task = cron.schedule(expression, async () => { - console.log(`[Bot ${botId}] 동기화 시작...`); - try { - const result = await syncBot(botId); - console.log( - `[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가` - ); - } catch (error) { - console.error(`[Bot ${botId}] 동기화 오류:`, error.message); - } - }); - schedulers.set(botId, task); - console.log( - `[Scheduler] Bot ${botId} 재등록 완료 (cron: ${expression})` - ); - } - } catch (error) { - console.error(`[Scheduler] Bot ${botId} 재등록 오류:`, error.message); - // 재등록 실패 시에만 stopped로 변경 - await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [ - botId, - ]); - console.log(`[Scheduler] Bot ${botId} 상태 동기화: stopped`); - } - } - } - } catch (error) { - console.error("[Scheduler] 상태 동기화 오류:", error.message); - } -} - -/** - * 서버 시작 시 실행 중인 봇들 스케줄 등록 - */ -export async function initScheduler() { - try { - const [bots] = await pool.query( - "SELECT id, check_interval, cron_expression FROM bots WHERE status = 'running'" - ); - - for (const bot of bots) { - registerBot(bot.id, bot.check_interval, bot.cron_expression); - } - - console.log(`[Scheduler] ${bots.length}개 봇 스케줄 등록됨`); - - // 10초 간격으로 상태 동기화 (DB status와 메모리 상태 일치 유지) - setInterval(syncBotStatuses, 10000); - console.log(`[Scheduler] 10초 간격 상태 동기화 시작`); - } catch (error) { - console.error("[Scheduler] 초기화 오류:", error); - } -} - -/** - * 봇 시작 - */ -export async function startBot(botId) { - const [bots] = await pool.query("SELECT * FROM bots WHERE id = ?", [botId]); - if (bots.length === 0) { - throw new Error("봇을 찾을 수 없습니다."); - } - - const bot = bots[0]; - - // 스케줄 등록 (cron_expression 우선 사용) - registerBot(botId, bot.check_interval, bot.cron_expression); - - // 상태 업데이트 - await pool.query( - "UPDATE bots SET status = 'running', error_message = NULL WHERE id = ?", - [botId] - ); - - // 즉시 1회 실행 - try { - await syncBot(botId); - } catch (error) { - console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message); - } -} - -/** - * 봇 정지 - */ -export async function stopBot(botId) { - unregisterBot(botId); - - await pool.query("UPDATE bots SET status = 'stopped' WHERE id = ?", [botId]); -} - -export default { - initScheduler, - registerBot, - unregisterBot, - startBot, - stopBot, - isBotRunning, -}; diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..d3e773f --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,47 @@ +import Fastify from 'fastify'; +import config from './config/index.js'; + +// 플러그인 +import dbPlugin from './plugins/db.js'; +import redisPlugin from './plugins/redis.js'; +import youtubeBotPlugin from './services/youtube/index.js'; +import xBotPlugin from './services/x/index.js'; +import schedulerPlugin from './plugins/scheduler.js'; + +export async function buildApp(opts = {}) { + const fastify = Fastify({ + logger: { + level: opts.logLevel || 'info', + }, + ...opts, + }); + + // config 데코레이터 등록 + fastify.decorate('config', config); + + // 플러그인 등록 (순서 중요) + await fastify.register(dbPlugin); + await fastify.register(redisPlugin); + await fastify.register(youtubeBotPlugin); + await fastify.register(xBotPlugin); + await fastify.register(schedulerPlugin); + + // 헬스 체크 엔드포인트 + fastify.get('/api/health', async () => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); + + // 봇 상태 조회 엔드포인트 + fastify.get('/api/bots', async () => { + const bots = fastify.scheduler.getBots(); + const statuses = await Promise.all( + bots.map(async bot => { + const status = await fastify.scheduler.getStatus(bot.id); + return { ...bot, ...status }; + }) + ); + return statuses; + }); + + return fastify; +} diff --git a/backend/src/config/bots.js b/backend/src/config/bots.js new file mode 100644 index 0000000..9efc14f --- /dev/null +++ b/backend/src/config/bots.js @@ -0,0 +1,37 @@ +export default [ + { + id: 'youtube-fromis9', + type: 'youtube', + channelId: 'UCXbRURMKT3H_w8dT-DWLIxA', + channelName: 'fromis_9', + cron: '*/2 * * * *', + enabled: true, + }, + { + id: 'youtube-studio', + type: 'youtube', + channelId: 'UCeUJ8B3krxw8zuDi19AlhaA', + channelName: '스프 : 스튜디오 프로미스나인', + cron: '*/2 * * * *', + enabled: true, + }, + { + id: 'youtube-musinsa', + type: 'youtube', + channelId: 'UCtfyAiqf095_0_ux8ruwGfA', + channelName: 'MUSINSA TV', + cron: '*/2 * * * *', + enabled: true, + titleFilter: '성수기', + defaultMemberId: 7, + extractMembersFromDesc: true, + }, + { + id: 'x-fromis9', + type: 'x', + username: 'realfromis_9', + nitterUrl: process.env.NITTER_URL || 'http://nitter:8080', + cron: '*/1 * * * *', + enabled: true, + }, +]; diff --git a/backend/src/config/index.js b/backend/src/config/index.js new file mode 100644 index 0000000..729017f --- /dev/null +++ b/backend/src/config/index.js @@ -0,0 +1,22 @@ +export default { + server: { + port: parseInt(process.env.PORT) || 80, + host: '0.0.0.0', + }, + db: { + host: process.env.DB_HOST || 'mariadb', + port: parseInt(process.env.DB_PORT) || 3306, + user: process.env.DB_USER || 'fromis9', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'fromis9', + connectionLimit: 10, + waitForConnections: true, + }, + redis: { + host: process.env.REDIS_HOST || 'fromis9-redis', + port: parseInt(process.env.REDIS_PORT) || 6379, + }, + youtube: { + apiKey: process.env.YOUTUBE_API_KEY, + }, +}; diff --git a/backend/src/plugins/db.js b/backend/src/plugins/db.js new file mode 100644 index 0000000..6383031 --- /dev/null +++ b/backend/src/plugins/db.js @@ -0,0 +1,25 @@ +import fp from 'fastify-plugin'; +import mysql from 'mysql2/promise'; + +async function dbPlugin(fastify, opts) { + const pool = mysql.createPool(fastify.config.db); + + // 연결 테스트 + try { + const conn = await pool.getConnection(); + fastify.log.info('MariaDB 연결 성공'); + conn.release(); + } catch (err) { + fastify.log.error('MariaDB 연결 실패:', err.message); + throw err; + } + + fastify.decorate('db', pool); + + fastify.addHook('onClose', async () => { + await pool.end(); + fastify.log.info('MariaDB 연결 종료'); + }); +} + +export default fp(dbPlugin, { name: 'db' }); diff --git a/backend/src/plugins/redis.js b/backend/src/plugins/redis.js new file mode 100644 index 0000000..587091c --- /dev/null +++ b/backend/src/plugins/redis.js @@ -0,0 +1,27 @@ +import fp from 'fastify-plugin'; +import Redis from 'ioredis'; + +async function redisPlugin(fastify, opts) { + const redis = new Redis({ + host: fastify.config.redis.host, + port: fastify.config.redis.port, + lazyConnect: true, + }); + + try { + await redis.connect(); + fastify.log.info('Redis 연결 성공'); + } catch (err) { + fastify.log.error('Redis 연결 실패:', err.message); + throw err; + } + + fastify.decorate('redis', redis); + + fastify.addHook('onClose', async () => { + await redis.quit(); + fastify.log.info('Redis 연결 종료'); + }); +} + +export default fp(redisPlugin, { name: 'redis' }); diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js new file mode 100644 index 0000000..2cfbd71 --- /dev/null +++ b/backend/src/plugins/scheduler.js @@ -0,0 +1,170 @@ +import fp from 'fastify-plugin'; +import cron from 'node-cron'; +import bots from '../config/bots.js'; + +const REDIS_PREFIX = 'bot:status:'; + +async function schedulerPlugin(fastify, opts) { + const tasks = new Map(); + + /** + * 봇 상태 Redis에 저장 + */ + async function updateStatus(botId, status) { + const current = await getStatus(botId); + const updated = { ...current, ...status, updatedAt: new Date().toISOString() }; + await fastify.redis.set(`${REDIS_PREFIX}${botId}`, JSON.stringify(updated)); + return updated; + } + + /** + * 봇 상태 Redis에서 조회 + */ + async function getStatus(botId) { + const data = await fastify.redis.get(`${REDIS_PREFIX}${botId}`); + if (data) { + return JSON.parse(data); + } + return { + status: 'stopped', + lastCheckAt: null, + lastAddedCount: 0, + totalAdded: 0, + errorMessage: null, + }; + } + + /** + * 봇 동기화 함수 가져오기 + */ + function getSyncFunction(bot) { + if (bot.type === 'youtube') { + return fastify.youtubeBot.syncNewVideos; + } else if (bot.type === 'x') { + return fastify.xBot.syncNewTweets; + } + return null; + } + + /** + * 봇 시작 + */ + async function startBot(botId) { + const bot = bots.find(b => b.id === botId); + if (!bot) { + throw new Error(`봇을 찾을 수 없습니다: ${botId}`); + } + + // 기존 태스크가 있으면 정지 + if (tasks.has(botId)) { + tasks.get(botId).stop(); + tasks.delete(botId); + } + + const syncFn = getSyncFunction(bot); + if (!syncFn) { + throw new Error(`지원하지 않는 봇 타입: ${bot.type}`); + } + + // cron 태스크 등록 + const task = cron.schedule(bot.cron, async () => { + fastify.log.info(`[${botId}] 동기화 시작`); + try { + const result = await syncFn(bot); + const status = await getStatus(botId); + await updateStatus(botId, { + status: 'running', + lastCheckAt: new Date().toISOString(), + lastAddedCount: result.addedCount, + totalAdded: (status.totalAdded || 0) + result.addedCount, + errorMessage: null, + }); + fastify.log.info(`[${botId}] 동기화 완료: ${result.addedCount}개 추가`); + } catch (err) { + await updateStatus(botId, { + status: 'error', + lastCheckAt: new Date().toISOString(), + errorMessage: err.message, + }); + fastify.log.error(`[${botId}] 동기화 오류: ${err.message}`); + } + }); + + tasks.set(botId, task); + await updateStatus(botId, { status: 'running' }); + fastify.log.info(`[${botId}] 스케줄 시작 (cron: ${bot.cron})`); + + // 즉시 1회 실행 + try { + const result = await syncFn(bot); + const status = await getStatus(botId); + await updateStatus(botId, { + lastCheckAt: new Date().toISOString(), + lastAddedCount: result.addedCount, + totalAdded: (status.totalAdded || 0) + result.addedCount, + }); + fastify.log.info(`[${botId}] 초기 동기화 완료: ${result.addedCount}개 추가`); + } catch (err) { + fastify.log.error(`[${botId}] 초기 동기화 오류: ${err.message}`); + } + } + + /** + * 봇 정지 + */ + async function stopBot(botId) { + if (tasks.has(botId)) { + tasks.get(botId).stop(); + tasks.delete(botId); + } + await updateStatus(botId, { status: 'stopped' }); + fastify.log.info(`[${botId}] 스케줄 정지`); + } + + /** + * 모든 활성 봇 시작 + */ + async function startAll() { + for (const bot of bots) { + if (bot.enabled) { + try { + await startBot(bot.id); + } catch (err) { + fastify.log.error(`[${bot.id}] 시작 실패: ${err.message}`); + } + } + } + } + + /** + * 모든 봇 정지 + */ + async function stopAll() { + for (const [botId, task] of tasks) { + task.stop(); + await updateStatus(botId, { status: 'stopped' }); + } + tasks.clear(); + } + + // 데코레이터 등록 + fastify.decorate('scheduler', { + startBot, + stopBot, + startAll, + stopAll, + getStatus, + getBots: () => bots, + }); + + // 앱 종료 시 모든 봇 정지 + fastify.addHook('onClose', async () => { + await stopAll(); + fastify.log.info('모든 봇 스케줄 정지'); + }); +} + +export default fp(schedulerPlugin, { + name: 'scheduler', + dependencies: ['db', 'redis', 'youtubeBot', 'xBot'], +}); diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..c37fee1 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,24 @@ +import { buildApp } from './app.js'; +import config from './config/index.js'; + +async function start() { + const app = await buildApp(); + + try { + // 서버 시작 + await app.listen({ + port: config.server.port, + host: config.server.host, + }); + + // 모든 봇 스케줄 시작 + await app.scheduler.startAll(); + + app.log.info(`서버 시작: http://${config.server.host}:${config.server.port}`); + } catch (err) { + app.log.error(err); + process.exit(1); + } +} + +start(); diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js new file mode 100644 index 0000000..82eaf09 --- /dev/null +++ b/backend/src/services/x/index.js @@ -0,0 +1,198 @@ +import fp from 'fastify-plugin'; +import { fetchTweets, fetchAllTweets, extractTitle, extractYoutubeVideoIds, extractProfile } from './scraper.js'; +import { fetchVideoInfo } from '../youtube/api.js'; +import { formatDate, formatTime } from '../../utils/date.js'; +import bots from '../../config/bots.js'; + +const X_CATEGORY_ID = 3; +const YOUTUBE_CATEGORY_ID = 2; +const PROFILE_CACHE_PREFIX = 'x_profile:'; +const PROFILE_TTL = 604800; // 7일 + +async function xBotPlugin(fastify, opts) { + /** + * 관리 중인 YouTube 채널 ID 목록 + */ + function getManagedChannelIds() { + return bots + .filter(b => b.type === 'youtube') + .map(b => b.channelId); + } + + /** + * X 프로필 캐시 저장 + */ + async function cacheProfile(username, profile) { + if (!profile.displayName && !profile.avatarUrl) return; + + const data = { + username, + displayName: profile.displayName, + avatarUrl: profile.avatarUrl, + updatedAt: new Date().toISOString(), + }; + await fastify.redis.setex( + `${PROFILE_CACHE_PREFIX}${username}`, + PROFILE_TTL, + JSON.stringify(data) + ); + } + + /** + * 트윗을 DB에 저장 + */ + async function saveTweet(tweet) { + // 중복 체크 (post_id로) + const [existing] = await fastify.db.query( + 'SELECT id FROM schedule_x WHERE post_id = ?', + [tweet.id] + ); + if (existing.length > 0) { + return null; + } + + const date = formatDate(tweet.time); + const time = formatTime(tweet.time); + const title = extractTitle(tweet.text); + + // schedules 테이블에 저장 + const [result] = await fastify.db.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [X_CATEGORY_ID, title, date, time] + ); + const scheduleId = result.insertId; + + // schedule_x 테이블에 저장 + await fastify.db.query( + 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', + [ + scheduleId, + tweet.id, + tweet.text, + tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, + ] + ); + + return scheduleId; + } + + /** + * YouTube 영상을 DB에 저장 (트윗에서 감지된 링크) + */ + async function saveYoutubeFromTweet(video) { + // 중복 체크 + const [existing] = await fastify.db.query( + 'SELECT id FROM schedule_youtube WHERE video_id = ?', + [video.videoId] + ); + if (existing.length > 0) { + return null; + } + + // schedules 테이블에 저장 + const [result] = await fastify.db.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] + ); + const scheduleId = result.insertId; + + // schedule_youtube 테이블에 저장 + await fastify.db.query( + 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', + [scheduleId, video.videoId, video.videoType, video.channelId, video.channelTitle] + ); + + return scheduleId; + } + + /** + * 트윗에서 YouTube 링크 처리 + */ + async function processYoutubeLinks(tweet) { + const videoIds = extractYoutubeVideoIds(tweet.text); + if (videoIds.length === 0) return 0; + + const managedChannels = getManagedChannelIds(); + let addedCount = 0; + + for (const videoId of videoIds) { + try { + const video = await fetchVideoInfo(videoId); + if (!video) continue; + + // 관리 중인 채널이면 스킵 + if (managedChannels.includes(video.channelId)) continue; + + const saved = await saveYoutubeFromTweet(video); + if (saved) addedCount++; + } catch (err) { + fastify.log.error(`YouTube 영상 처리 오류 (${videoId}): ${err.message}`); + } + } + + return addedCount; + } + + /** + * 최근 트윗 동기화 (정기 실행) + */ + async function syncNewTweets(bot) { + const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username); + + // 프로필 캐시 업데이트 + await cacheProfile(bot.username, profile); + + let addedCount = 0; + let ytAddedCount = 0; + + for (const tweet of tweets) { + const scheduleId = await saveTweet(tweet); + if (scheduleId) { + addedCount++; + // YouTube 링크 처리 + ytAddedCount += await processYoutubeLinks(tweet); + } + } + + return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount }; + } + + /** + * 전체 트윗 동기화 (초기화) + */ + async function syncAllTweets(bot) { + const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log); + + let addedCount = 0; + let ytAddedCount = 0; + + for (const tweet of tweets) { + const scheduleId = await saveTweet(tweet); + if (scheduleId) { + addedCount++; + ytAddedCount += await processYoutubeLinks(tweet); + } + } + + return { addedCount: addedCount + ytAddedCount, tweetCount: addedCount, ytCount: ytAddedCount }; + } + + /** + * X 프로필 조회 + */ + async function getProfile(username) { + const data = await fastify.redis.get(`${PROFILE_CACHE_PREFIX}${username}`); + return data ? JSON.parse(data) : null; + } + + fastify.decorate('xBot', { + syncNewTweets, + syncAllTweets, + getProfile, + }); +} + +export default fp(xBotPlugin, { + name: 'xBot', + dependencies: ['db', 'redis'], +}); diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js new file mode 100644 index 0000000..47d5295 --- /dev/null +++ b/backend/src/services/x/scraper.js @@ -0,0 +1,195 @@ +import { parseNitterDateTime } from '../../utils/date.js'; + +/** + * 트윗 텍스트에서 첫 문단 추출 (title용) + */ +export function extractTitle(text) { + if (!text) return ''; + const paragraphs = text.split(/\n\n+/); + return paragraphs[0]?.trim() || ''; +} + +/** + * HTML에서 이미지 URL 추출 + */ +export function extractImageUrls(html) { + const urls = []; + const regex = /href="\/pic\/(orig\/)?media%2F([^"]+)"/g; + let match; + while ((match = regex.exec(html)) !== null) { + const mediaPath = decodeURIComponent(match[2]); + const cleanPath = mediaPath.split('%3F')[0].split('?')[0]; + urls.push(`https://pbs.twimg.com/media/${cleanPath}`); + } + return [...new Set(urls)]; +} + +/** + * 텍스트에서 유튜브 videoId 추출 + */ +export function extractYoutubeVideoIds(text) { + if (!text) return []; + const ids = new Set(); + + // youtu.be/{id} + const shortRegex = /youtu\.be\/([a-zA-Z0-9_-]{11})/g; + let m; + while ((m = shortRegex.exec(text)) !== null) { + ids.add(m[1]); + } + + // youtube.com/watch?v={id} + const watchRegex = /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g; + while ((m = watchRegex.exec(text)) !== null) { + ids.add(m[1]); + } + + // youtube.com/shorts/{id} + const shortsRegex = /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g; + while ((m = shortsRegex.exec(text)) !== null) { + ids.add(m[1]); + } + + return [...ids]; +} + +/** + * HTML에서 프로필 정보 추출 + */ +export function extractProfile(html) { + const profile = { displayName: null, avatarUrl: null }; + + const nameMatch = html.match(/class="profile-card-fullname"[^>]*title="([^"]+)"/); + if (nameMatch) { + profile.displayName = nameMatch[1].trim(); + } + + const avatarMatch = html.match(/class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/); + if (avatarMatch) { + let url = avatarMatch[1]; + const encodedMatch = url.match(/\/pic\/(.+)/); + if (encodedMatch) { + url = decodeURIComponent(encodedMatch[1]); + } + profile.avatarUrl = url; + } + + return profile; +} + +/** + * HTML에서 트윗 목록 파싱 + */ +export function parseTweets(html, username) { + const tweets = []; + const containers = html.split('class="timeline-item '); + + for (let i = 1; i < containers.length; i++) { + const container = containers[i]; + + // 고정/리트윗 제외 + const isPinned = container.includes('class="pinned"'); + const isRetweet = container.includes('class="retweet-header"'); + if (isPinned || isRetweet) continue; + + // 트윗 ID + const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); + if (!idMatch) continue; + const id = idMatch[1]; + + // 시간 + const timeMatch = container.match(/]*>]*title="([^"]+)"/); + const time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; + if (!time) continue; + + // 텍스트 + const contentMatch = container.match(/
]*>([\s\S]*?)<\/div>/); + let text = ''; + if (contentMatch) { + text = contentMatch[1] + .replace(//g, '\n') + .replace(/]*>([^<]*)<\/a>/g, '$1') + .replace(/<[^>]+>/g, '') + .trim(); + } + + // 이미지 + const imageUrls = extractImageUrls(container); + + tweets.push({ + id, + time, + text, + imageUrls, + url: `https://x.com/${username}/status/${id}`, + }); + } + + return tweets; +} + +/** + * Nitter에서 트윗 수집 (첫 페이지만) + */ +export async function fetchTweets(nitterUrl, username) { + const url = `${nitterUrl}/${username}`; + const res = await fetch(url); + const html = await res.text(); + + // 프로필 정보 + const profile = extractProfile(html); + + // 트윗 파싱 + const tweets = parseTweets(html, username); + + return { tweets, profile }; +} + +/** + * Nitter에서 전체 트윗 수집 (페이지네이션) + */ +export async function fetchAllTweets(nitterUrl, username, log) { + const allTweets = []; + let cursor = null; + let pageNum = 1; + let emptyCount = 0; + + while (true) { + const url = cursor + ? `${nitterUrl}/${username}?cursor=${cursor}` + : `${nitterUrl}/${username}`; + + log?.info(`[페이지 ${pageNum}] 스크래핑 중...`); + + try { + const res = await fetch(url); + const html = await res.text(); + const tweets = parseTweets(html, username); + + if (tweets.length === 0) { + emptyCount++; + if (emptyCount >= 3) break; + } else { + emptyCount = 0; + allTweets.push(...tweets); + log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`); + } + + // 다음 페이지 cursor + const cursorMatch = html.match(/class="show-more"[^>]*>\s* setTimeout(r, 1000)); + } catch (err) { + log?.error(` -> 오류: ${err.message}`); + emptyCount++; + if (emptyCount >= 5) break; + await new Promise(r => setTimeout(r, 3000)); + } + } + + return allTweets; +} diff --git a/backend/src/services/youtube/api.js b/backend/src/services/youtube/api.js new file mode 100644 index 0000000..e754038 --- /dev/null +++ b/backend/src/services/youtube/api.js @@ -0,0 +1,181 @@ +import config from '../../config/index.js'; +import { formatDate, formatTime } from '../../utils/date.js'; + +const API_KEY = config.youtube.apiKey; +const API_BASE = 'https://www.googleapis.com/youtube/v3'; + +/** + * ISO 8601 duration (PT1M30S) → 초 변환 + */ +function parseDuration(duration) { + const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return 0; + return ( + parseInt(match[1] || 0) * 3600 + + parseInt(match[2] || 0) * 60 + + parseInt(match[3] || 0) + ); +} + +/** + * 영상 URL 생성 + */ +function getVideoUrl(videoId, isShorts) { + return isShorts + ? `https://www.youtube.com/shorts/${videoId}` + : `https://www.youtube.com/watch?v=${videoId}`; +} + +/** + * 채널의 업로드 플레이리스트 ID 조회 + */ +async function getUploadsPlaylistId(channelId) { + const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + if (data.error) { + throw new Error(data.error.message); + } + if (!data.items?.length) { + throw new Error('채널을 찾을 수 없습니다'); + } + + return data.items[0].contentDetails.relatedPlaylists.uploads; +} + +/** + * 영상 ID 목록으로 duration 조회 (Shorts 판별용) + */ +async function getVideoDurations(videoIds) { + const url = `${API_BASE}/videos?part=contentDetails&id=${videoIds.join(',')}&key=${API_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + const durations = {}; + if (data.items) { + for (const v of data.items) { + const seconds = parseDuration(v.contentDetails.duration); + durations[v.id] = seconds <= 60; + } + } + return durations; +} + +/** + * 최근 N개 영상 조회 + */ +export async function fetchRecentVideos(channelId, maxResults = 10) { + const uploadsId = await getUploadsPlaylistId(channelId); + + const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + if (data.error) { + throw new Error(data.error.message); + } + + const videoIds = data.items.map(item => item.snippet.resourceId.videoId); + const shortsMap = await getVideoDurations(videoIds); + + return data.items.map(item => { + const { snippet } = item; + const videoId = snippet.resourceId.videoId; + const isShorts = shortsMap[videoId] || false; + const publishedAt = new Date(snippet.publishedAt); + + return { + videoId, + title: snippet.title, + description: snippet.description || '', + channelId: snippet.channelId, + channelTitle: snippet.channelTitle, + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoType: isShorts ? 'shorts' : 'video', + videoUrl: getVideoUrl(videoId, isShorts), + }; + }); +} + +/** + * 전체 영상 조회 (페이지네이션) + */ +export async function fetchAllVideos(channelId) { + const uploadsId = await getUploadsPlaylistId(channelId); + const videos = []; + let pageToken = ''; + + do { + const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=50&key=${API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`; + const res = await fetch(url); + const data = await res.json(); + + if (data.error) { + throw new Error(data.error.message); + } + + const videoIds = data.items.map(item => item.snippet.resourceId.videoId); + const shortsMap = await getVideoDurations(videoIds); + + for (const item of data.items) { + const { snippet } = item; + const videoId = snippet.resourceId.videoId; + const isShorts = shortsMap[videoId] || false; + const publishedAt = new Date(snippet.publishedAt); + + videos.push({ + videoId, + title: snippet.title, + description: snippet.description || '', + channelId: snippet.channelId, + channelTitle: snippet.channelTitle, + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoType: isShorts ? 'shorts' : 'video', + videoUrl: getVideoUrl(videoId, isShorts), + }); + } + + pageToken = data.nextPageToken || ''; + } while (pageToken); + + // 과거순 정렬 + videos.sort((a, b) => a.publishedAt - b.publishedAt); + return videos; +} + +/** + * 단일 영상 정보 조회 + */ +export async function fetchVideoInfo(videoId) { + const url = `${API_BASE}/videos?part=snippet,contentDetails&id=${videoId}&key=${API_KEY}`; + const res = await fetch(url); + const data = await res.json(); + + if (!data.items?.length) { + return null; + } + + const video = data.items[0]; + const { snippet, contentDetails } = video; + const seconds = parseDuration(contentDetails.duration); + const isShorts = seconds > 0 && seconds <= 60; + const publishedAt = new Date(snippet.publishedAt); + + return { + videoId, + title: snippet.title, + description: snippet.description || '', + channelId: snippet.channelId, + channelTitle: snippet.channelTitle, + publishedAt, + date: formatDate(publishedAt), + time: formatTime(publishedAt), + videoType: isShorts ? 'shorts' : 'video', + videoUrl: getVideoUrl(videoId, isShorts), + }; +} diff --git a/backend/src/services/youtube/index.js b/backend/src/services/youtube/index.js new file mode 100644 index 0000000..eae87d6 --- /dev/null +++ b/backend/src/services/youtube/index.js @@ -0,0 +1,141 @@ +import fp from 'fastify-plugin'; +import { fetchRecentVideos, fetchAllVideos } from './api.js'; +import bots from '../../config/bots.js'; + +const YOUTUBE_CATEGORY_ID = 2; + +async function youtubeBotPlugin(fastify, opts) { + /** + * 멤버 이름 맵 조회 + */ + async function getMemberNameMap() { + const [rows] = await fastify.db.query('SELECT id, name FROM members'); + const map = {}; + for (const r of rows) { + map[r.name] = r.id; + } + return map; + } + + /** + * description에서 멤버 추출 + */ + function extractMemberIds(description, memberNameMap) { + if (!description) return []; + const ids = []; + for (const [name, id] of Object.entries(memberNameMap)) { + if (description.includes(name)) { + ids.push(id); + } + } + return ids; + } + + /** + * 영상을 DB에 저장 + */ + async function saveVideo(video, bot) { + // 중복 체크 (video_id로) + const [existing] = await fastify.db.query( + 'SELECT id FROM schedule_youtube WHERE video_id = ?', + [video.videoId] + ); + if (existing.length > 0) { + return null; + } + + // 커스텀 설정 적용 + if (bot.titleFilter && !video.title.includes(bot.titleFilter)) { + return null; + } + + // schedules 테이블에 저장 + const [result] = await fastify.db.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] + ); + const scheduleId = result.insertId; + + // schedule_youtube 테이블에 저장 + await fastify.db.query( + 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', + [scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName] + ); + + // 멤버 연결 (커스텀 설정) + if (bot.defaultMemberId || bot.extractMembersFromDesc) { + const memberIds = []; + if (bot.defaultMemberId) { + memberIds.push(bot.defaultMemberId); + } + if (bot.extractMembersFromDesc) { + const nameMap = await getMemberNameMap(); + memberIds.push(...extractMemberIds(video.description, nameMap)); + } + if (memberIds.length > 0) { + const uniqueIds = [...new Set(memberIds)]; + const values = uniqueIds.map(id => [scheduleId, id]); + await fastify.db.query( + 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', + [values] + ); + } + } + + return scheduleId; + } + + /** + * 최근 영상 동기화 (정기 실행) + */ + async function syncNewVideos(bot) { + const videos = await fetchRecentVideos(bot.channelId, 10); + let addedCount = 0; + + for (const video of videos) { + const scheduleId = await saveVideo(video, bot); + if (scheduleId) { + addedCount++; + } + } + + return { addedCount, total: videos.length }; + } + + /** + * 전체 영상 동기화 (초기화) + */ + async function syncAllVideos(bot) { + const videos = await fetchAllVideos(bot.channelId); + let addedCount = 0; + + for (const video of videos) { + const scheduleId = await saveVideo(video, bot); + if (scheduleId) { + addedCount++; + } + } + + return { addedCount, total: videos.length }; + } + + /** + * 관리 중인 채널 ID 목록 + */ + function getManagedChannelIds() { + return bots + .filter(b => b.type === 'youtube') + .map(b => b.channelId); + } + + fastify.decorate('youtubeBot', { + syncNewVideos, + syncAllVideos, + getManagedChannelIds, + }); +} + +export default fp(youtubeBotPlugin, { + name: 'youtubeBot', + dependencies: ['db'], +}); diff --git a/backend/src/utils/date.js b/backend/src/utils/date.js new file mode 100644 index 0000000..6028288 --- /dev/null +++ b/backend/src/utils/date.js @@ -0,0 +1,40 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc.js'; +import timezone from 'dayjs/plugin/timezone.js'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +const KST = 'Asia/Seoul'; + +/** + * UTC Date를 KST dayjs 객체로 변환 + */ +export function toKST(date) { + return dayjs(date).tz(KST); +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 (KST) + */ +export function formatDate(date) { + return dayjs(date).tz(KST).format('YYYY-MM-DD'); +} + +/** + * 시간을 HH:mm:ss 형식으로 포맷 (KST) + */ +export function formatTime(date) { + return dayjs(date).tz(KST).format('HH:mm:ss'); +} + +/** + * Nitter 날짜 문자열 파싱 + * 예: "Jan 15, 2026 · 10:30 PM UTC" + */ +export function parseNitterDateTime(timeStr) { + if (!timeStr) return null; + const cleaned = timeStr.replace(' · ', ' ').replace(' UTC', ''); + const date = new Date(cleaned + ' UTC'); + return isNaN(date.getTime()) ? null : date; +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 614432d..54bf68f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,12 +14,12 @@ services: - db restart: unless-stopped - # 백엔드 - Express API 서버 + # 백엔드 - Fastify API 서버 fromis9-backend: image: node:20-alpine container_name: fromis9-backend working_dir: /app - command: sh -c "apk add --no-cache ffmpeg && npm install && node server.js" + command: sh -c "apk add --no-cache ffmpeg && npm install && npm run dev" env_file: - .env environment: diff --git a/docker-compose.yml b/docker-compose.yml index 2b8ca96..a862e80 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,13 @@ services: - app restart: unless-stopped + redis: + image: redis:7-alpine + container_name: fromis9-redis + networks: + - app + restart: unless-stopped + networks: app: external: true diff --git a/frontend/.env b/frontend/.env index 6400e98..6cff0c1 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1 @@ VITE_KAKAO_JS_KEY=84b3c657c3de7d1ca89e1fa33455b8da -VITE_KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea