commit 2543e56ec4ea1528ea14802b46dd0c740e70d65b Author: JamesFlare1212 Date: Sat May 10 23:39:39 2025 -0400 init: port to typescript and bun diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36ecc09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +nkcs-engage.cookie.txt +.env +redis_data \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..127d525 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM oven/bun:latest + +ENV NODE_ENV=production + +WORKDIR /usr/src/app + +COPY package.json bun.lock ./ + +RUN bun install --production + +COPY . . + +EXPOSE 3000 + +CMD ["bun", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6233def --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +## How to Run + +copy `example.env` + +```bash +cp example.env .env +``` + +edit `.env` + +`API_USERNAME` is your engage username in URL-encode. +`API_PASSWORD` is your engage password in URL-encode. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..3efff9f --- /dev/null +++ b/bun.lock @@ -0,0 +1,202 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "dsas-cca-backend-bun", + "dependencies": { + "axios": "^1.9.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "p-limit": "^6.2.0", + "pangu": "^4.0.7", + "uuid": "^11.1.0", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + + "@types/node": ["@types/node@22.15.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.9.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg=="], + + "body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], + + "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], + + "finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "form-data": ["form-data@4.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "mime-types": "^2.1.12" } }, "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + + "pangu": ["pangu@4.0.7", "", { "bin": { "pangu": "./dist/node/cli.js" } }, "sha512-weZKJIwwy5gjt4STGVUH9bix3BGk7wZ2ahtIypwe3e/mllsrIZIvtfLx1dPX56GcpZFOCFKmeqI1qVuB9enRzA=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], + + "serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "side-channel": ["side-channel@1.1.0", "", { "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" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], + + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..a390896 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,34 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: dsas-cca-backend + ports: + - "${PORT}:${PORT}" + env_file: + - .env + restart: unless-stopped + depends_on: + - redis + networks: + - cca_network + + redis: + image: "redis:7.2-alpine" + container_name: dsas-cca-redis + command: redis-server --requirepass "dsas-cca" + volumes: + - ./redis_data:/data + restart: unless-stopped + networks: + - cca_network + healthcheck: + test: ["CMD", "redis-cli", "-a", "dsas-cca", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +networks: + cca_network: + driver: bridge diff --git a/engage-api/get-activity.ts b/engage-api/get-activity.ts new file mode 100644 index 0000000..acf69b2 --- /dev/null +++ b/engage-api/get-activity.ts @@ -0,0 +1,346 @@ +// ./engage-api/get-activity.ts +import axios from 'axios'; +import { readFile, writeFile, unlink } from 'fs/promises'; +import { resolve } from 'path'; +import { logger } from '../utils/logger'; + +// Define interfaces for our data structures +interface ActivityResponse { + d: string; + isError?: boolean; + [key: string]: any; +} + +// --- Custom Error for Authentication --- +class AuthenticationError extends Error { + status: number; + + constructor(message: string = "Authentication failed, cookie may be invalid.", status?: number) { + super(message); + this.name = "AuthenticationError"; + this.status = status || 0; + } +} + +// In Bun, we can use import.meta.dir instead of the Node.js __dirname approach +const COOKIE_FILE_PATH = resolve(import.meta.dir, 'nkcs-engage.cookie.txt'); +let _inMemoryCookie: string | null = null; + +// --- Cookie Cache Helper Functions --- +async function loadCachedCookie(): Promise { + if (_inMemoryCookie) { + logger.debug("Using in-memory cached cookie."); + return _inMemoryCookie; + } + try { + const cookieFromFile = await readFile(COOKIE_FILE_PATH, 'utf8'); + if (cookieFromFile) { + _inMemoryCookie = cookieFromFile; + logger.debug("Loaded cookie from file cache."); + return _inMemoryCookie; + } + } catch (err: any) { + if (err.code === 'ENOENT') { + logger.debug("Cookie cache file not found. No cached cookie loaded."); + } else { + logger.warn("Error loading cookie from file:", err.message); + } + } + return null; +} + +async function saveCookieToCache(cookieString: string): Promise { + if (!cookieString) { + logger.warn("Attempted to save an empty or null cookie. Aborting save."); + return; + } + _inMemoryCookie = cookieString; + try { + await writeFile(COOKIE_FILE_PATH, cookieString, 'utf8'); + logger.debug("Cookie saved to file cache."); + } catch (err: any) { + logger.error("Error saving cookie to file:", err.message); + } +} + +async function clearCookieCache(): Promise { + _inMemoryCookie = null; + try { + await unlink(COOKIE_FILE_PATH); + logger.debug("Cookie cache file deleted."); + } catch (err: any) { + if (err.code !== 'ENOENT') { + logger.error("Error deleting cookie file:", err.message); + } else { + logger.debug("Cookie cache file did not exist, no need to delete."); + } + } +} + +async function testCookieValidity(cookieString: string): Promise { + if (!cookieString) return false; + logger.debug("Testing cookie validity..."); + + const MAX_RETRIES = 3; + let attempt = 0; + + while (attempt < MAX_RETRIES) { + try { + attempt++; + const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails'; + const headers = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Cookie': cookieString, + 'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)', + }; + const payload = { "activityID": "3350" }; + + logger.debug(`Attempt ${attempt}/${MAX_RETRIES}`); + await axios.post(url, payload, { headers, timeout: 20000 }); + + logger.debug("Cookie test successful (API responded 2xx). Cookie is valid."); + return true; + } catch (error: any) { + logger.warn(`Cookie validity test failed (attempt ${attempt}/${MAX_RETRIES}).`); + if (error.response) { + logger.warn(`Cookie test API response status: ${error.response.status}.`); + } else { + logger.warn(`Network/other error: ${error.message}`); + } + + if (attempt >= MAX_RETRIES) { + logger.warn("Max retries reached. Cookie is likely invalid or expired."); + return false; + } + } + } + return false; +} + +// --- Core API Interaction Functions --- +async function getSessionId(): Promise { + const url = 'https://engage.nkcswx.cn/Login.aspx'; + try { + const response = await axios.get(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)' } + }); + const setCookieHeader = response.headers['set-cookie']; + if (setCookieHeader && setCookieHeader.length > 0) { + const sessionIdCookie = setCookieHeader.find(cookie => cookie.trim().startsWith('ASP.NET_SessionId=')); + if (sessionIdCookie) { + logger.debug('ASP.NET_SessionId created'); + return sessionIdCookie.split(';')[0] || null; // Ensure a fallback to `null` if splitting fails + } + return null; // Explicitly return `null` if no cookie is found + } + logger.error("No ASP.NET_SessionId cookie found in Set-Cookie header."); + return null; + } catch (error: any) { + logger.error(`Error in getSessionId: ${error.response ? `${error.response.status} - ${error.response.statusText}` : error.message}`); + throw error; + } +} + +async function getMSAUTH(sessionId: string, userName: string, userPwd: string, templateFilePath: string): Promise { + const url = 'https://engage.nkcswx.cn/Login.aspx'; + try { + let templateData = await readFile(templateFilePath, 'utf8'); + const postData = templateData + .replace('{{USERNAME}}', userName) + .replace('{{PASSWORD}}', userPwd); + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': sessionId, + 'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)', + 'Referer': 'https://engage.nkcswx.cn/Login.aspx' + }; + logger.debug('Getting .ASPXFORMSAUTH'); + const response = await axios.post(url, postData, { + headers, + maxRedirects: 0, + validateStatus: (status) => status >= 200 && status < 400 + }); + const setCookieHeader = response.headers['set-cookie']; + let formsAuthCookieValue = null; + if (setCookieHeader && setCookieHeader.length > 0) { + const aspxAuthCookies = setCookieHeader.filter(cookie => cookie.trim().startsWith('.ASPXFORMSAUTH=')); + if (aspxAuthCookies.length > 0) { + for (let i = aspxAuthCookies.length - 1; i >= 0; i--) { + const cookieCandidateParts = aspxAuthCookies[i].split(';'); + if (cookieCandidateParts.length > 0 && cookieCandidateParts[0] !== undefined) { // Explicit check + const firstPart = cookieCandidateParts[0].trim(); + if (firstPart.length > '.ASPXFORMSAUTH='.length && firstPart.substring('.ASPXFORMSAUTH='.length).length > 0) { + formsAuthCookieValue = firstPart; + break; + } + } + } + } + } + if (formsAuthCookieValue) { + logger.debug('.ASPXFORMSAUTH cookie obtained.'); + return formsAuthCookieValue; + } else { + logger.error("No valid .ASPXFORMSAUTH cookie found. Headers:", setCookieHeader || "none"); + return null; + } + } catch (error: any) { + if (error.code === 'ENOENT') logger.error(`Error: Template file '${templateFilePath}' not found.`); + else logger.error(`Error in getMSAUTH: ${error.message}`); + throw error; + } +} + +async function getCompleteCookies(userName: string, userPwd: string, templateFilePath: string): Promise { + logger.debug('Attempting to get complete cookie string (login process).'); + const sessionId = await getSessionId(); + if (!sessionId) throw new Error("Login failed: Could not obtain ASP.NET_SessionId."); + + const msAuth = await getMSAUTH(sessionId, userName, userPwd, templateFilePath); + if (!msAuth) throw new Error("Login failed: Could not obtain .ASPXFORMSAUTH cookie."); + + return `${sessionId}; ${msAuth}`; +} + +async function getActivityDetailsRaw( + activityId: string, + cookies: string, + maxRetries: number = 3, + timeoutMilliseconds: number = 20000 +): Promise { + const url = 'https://engage.nkcswx.cn/Services/ActivitiesService.asmx/GetActivityDetails'; + const headers = { + 'Content-Type': 'application/json; charset=UTF-8', + 'Cookie': cookies, + 'User-Agent': 'Mozilla/5.0 (Bun DSAS-CCA get-activity Module)', + 'X-Requested-With': 'XMLHttpRequest' + }; + const payload = { "activityID": String(activityId) }; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const response = await axios.post(url, payload, { + headers, + timeout: timeoutMilliseconds, + responseType: 'text' + }); + const outerData = JSON.parse(response.data); + if (outerData && typeof outerData.d === 'string') { + const innerData = JSON.parse(outerData.d); + if (innerData.isError) { + logger.warn(`API reported isError:true for activity ${activityId}.`); + return null; + } + return response.data; + } else { + logger.error(`Unexpected API response structure for activity ${activityId}.`); + } + } catch (error: any) { + // Check if response status is in 4xx range (400-499) to trigger auth error + if (error.response && error.response.status >= 400 && error.response.status < 500) { + logger.warn(`Authentication error (${error.response.status}) while fetching activity ${activityId}. Cookie may be invalid.`); + throw new AuthenticationError(`Received ${error.response.status} for activity ${activityId}`, error.response.status); + } + logger.error(`Attempt ${attempt + 1}/${maxRetries} for activity ${activityId} failed: ${error.message}`); + if (error.response) { + logger.error(`Status: ${error.response.status}, Data (getActivityDetailsRaw): ${ String(error.response.data).slice(0,100)}...`); + } + if (attempt === maxRetries - 1) { + logger.error(`All ${maxRetries} retries failed for activity ${activityId}.`); + throw error; + } + await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))); + } + } + return null; +} + +/** + * Main exported function. Handles cookie caching, validation, re-authentication, and fetches activity details. + * @param activityId - The ID of the activity to fetch. + * @param userName - URL-encoded username. + * @param userPwd - URL-encoded password. + * @param templateFileName - Name of the login template file. + * @param forceLogin - If true, bypasses cached cookie and forces a new login. + * @returns The parsed JSON object of activity details, or null on failure. + */ +export async function fetchActivityData( + activityId: string, + userName: string, + userPwd: string, + templateFileName: string = "login_template.txt", + forceLogin: boolean = false +): Promise { + let currentCookie = forceLogin ? null : await loadCachedCookie(); + + if (forceLogin && currentCookie) { + await clearCookieCache(); + currentCookie = null; + } + + if (currentCookie) { + const isValid = await testCookieValidity(currentCookie); + if (!isValid) { + logger.info("Cached cookie test failed or cookie expired. Clearing cache."); + await clearCookieCache(); + currentCookie = null; + } else { + logger.info("Using valid cached cookie."); + } + } + + if (!currentCookie) { + logger.info(forceLogin ? "Forcing new login." : "No valid cached cookie found or cache bypassed. Attempting login..."); + try { + currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName)); + await saveCookieToCache(currentCookie); + } catch (loginError) { + logger.error(`Login process failed: ${(loginError as Error).message}`); + return null; + } + } + + if (!currentCookie) { + logger.error("Critical: No cookie available after login attempt. Cannot fetch activity data."); + return null; + } + + try { + const rawActivityDetailsString = await getActivityDetailsRaw(activityId, currentCookie); + if (rawActivityDetailsString) { + const parsedOuter = JSON.parse(rawActivityDetailsString); + return JSON.parse(parsedOuter.d); + } + logger.warn(`No data returned from getActivityDetailsRaw for activity ${activityId}, but no authentication error was thrown.`); + return null; + } catch (error) { + if (error instanceof AuthenticationError) { + logger.warn(`Initial fetch failed with AuthenticationError (Status: ${error.status}). Cookie was likely invalid. Attempting re-login and one retry.`); + await clearCookieCache(); + + try { + logger.info("Attempting re-login due to authentication failure..."); + currentCookie = await getCompleteCookies(userName, userPwd, resolve(import.meta.dir, templateFileName)); + await saveCookieToCache(currentCookie); + + logger.info("Re-login successful. Retrying request for activity details once..."); + const rawActivityDetailsStringRetry = await getActivityDetailsRaw(activityId, currentCookie); + if (rawActivityDetailsStringRetry) { + const parsedOuterRetry = JSON.parse(rawActivityDetailsStringRetry); + return JSON.parse(parsedOuterRetry.d); + } + logger.warn(`Still no details for activity ${activityId} after re-login and retry.`); + return null; + } catch (retryLoginOrFetchError) { + logger.error(`Error during re-login or retry fetch for activity ${activityId}: ${(retryLoginOrFetchError as Error).message}`); + return null; + } + } else { + logger.error(`Failed to fetch activity data for ${activityId} due to non-authentication error: ${(error as Error).message}`); + return null; + } + } +} + +// Optionally +export { clearCookieCache, testCookieValidity }; \ No newline at end of file diff --git a/engage-api/login_template.txt b/engage-api/login_template.txt new file mode 100644 index 0000000..0ccecb9 --- /dev/null +++ b/engage-api/login_template.txt @@ -0,0 +1 @@ +ctl00_ctl13_TSSM=%3BTelerik.Web.UI%2C+Version%3D2021.3.1111.45%2C+Culture%3Dneutral%2C+PublicKeyToken%3D121fae78165ba3d4%3Aen-GB%3Ab406acc5-0028-4c73-8915-a9da355d848a%3A1c2121e&ctl00_ScriptManager1_HiddenField=&__LASTFOCUS=&__EVENTTARGET=&__EVENTARGUMENT=&__VIEWSTATE=pKaH5F3otHosG4r24lmDPRIspWqbbAMpsJLanFjRNXqr5e1T8Q6iZwjjXLdcLT0v3GWv7zQ9bK6ODye657W4jPwllrGhgeG%2BVUnPB85iX3B%2FcYOky1by2z5hD41Jfg9fnlN1oOtJ8ihjzzgufVv%2Bktc7RmfUEt%2BhAAQ%2FXxW2fno9OjYcxO4jSdW5psDv7wGk9JvHBiDjBECVFcPgwFCnaIphrC%2BFcJgRL1NMT2HBDd%2FwAoy4EwsUZlvriLMfD7Tj%2F5B8tsLTLPHK756PHjOzheLZ5Es2rmzMB19g0OL1xb7i42VA%2FsUY%2BevQuOJLyBm2e1Fphk4eVFCVMkX%2BF8%2B7b6jphK%2FbXFLF7oKEcJWctFn7LNjiiqOEYIv0BOronH5mNowjRAxYI97cF90OuCX%2BI2kK62Okr%2FZ7MADZWnsKYah6fuOBXGgYzRBVU5affWya2rkygKcCRFdh4LzB6j3pJsqee37bvIIvS%2FzDD6NzxSjilChamXBEnEGzC3EWfG0w3%2BDm8kQQKBPVmc4QU10KMZzYkjdzjyJNhA8QncnYg9xdWdCJ3bKu5GY%2FFhf1y8ACFA1FkwTaDxZy%2FT42BS1L5zxMWGQ24Ch0J4IgOKHkIHT0RVfLuZLE3LkLCI%2BMQ3mbiTloWJJ265lRre5wo76CExyyVHoh3ZR8L44e%2BusvVp44tisI0KdNuYlTm2BfNIWFZ7c36dZoNPb8Bp%2BcTRUneBghRoR9Jwr42AY2Z%2FfpeOwQpPl21lzOMuUEgi4MZRY2PFSZCyEeWMY%2FxqLqdC%2F0ITBULzVGdRSOqf2aXIjYEoJTfmkeYliZL21z7Pzxgm%2F%2BFX8RPcKWqobGDDlQvmiitC9%2FtmahiQxdavc3bNpNv%2BLvK3QXZuWxBnAzrwKADPrap4iNNp48ZVuD9IMTcccaPhXshXKSZkxdlDtPYwXPtKOBAsJtXmjWcy%2BPr8wvBUXlzxraEAMhmjtjklaGjZUgen25vrCgeUfOyswdpvmKuEtsY5wObSz09ftChpwyPDQVYgwbDViU8yNH%2Bf0x2C0stsRu1KZg%2FpGG6786DcyLgzQ2muju02aSoDTzv1oUPetGmeHiiT3KwJsSXCqDJuk9z9Z3FAW0DpYt1dN%2BBZWu%2BXHEkv9M2rfcCeYwfLJhI%2FNZMXZuHhGGYTxwkIg9OTud5PbDP1A0gr7KhlRcfZ47DzZZA%2BLebtxoVvSeaFdiGCZtbr5wZAxrdu3knyFRiRR5XR3S0FFSqxsTVGZEyJPu890WLNFgonQ85nQmdD8U6mtwtFxr9CHeR%2BYRwvPmrLr%2Blz7WpPpayMR9HRreg2TjBIRMeQjO6PFgNtytMzFCFJdfcsB7oM6eDVDfVyAPrFeFCHrY7TJCGB0HKvJSXhKMYhzekwtJ2J47B5XAduRUITkpRIMqRG0dv0RKoOozKPXBnEJbdUl%2BnE5W6Sxh8L9VrBgRVjHwfekfBGI4c7ZHVUW1FYZqb6YYC1YJa2LL5CvzGbhbc2biJ3u9U51ug4%2FXDNgXic%2BfUWSzALUZoJa7YOSpnKKm01DNkqCVA2JGJwT8M2C62vTevGGAJ5QQUT%2FmyJi2LdOOkIXTuXaEnul1ZEgdubhov1QYRWwimpjCtuTc7i7aW265LYCUdusbZsgqGDihuKuwA4Ifpw32R0g6aUvuwjPI9yYOhtzewpFHkX8nY0cV%2FJr%2F2tlaLVRqybZ%2B5RgqN3RTDqPgX6kxnFrxrvBB%2Fml%2FgN3mG9XIJiT9Tza4vR3ARB88A%2BXb1yrkVBTLRPXY3Zjy1ZKO6Px0WKliitlegU2h%2Bv089Hmyq%2FafCuNLwFlJSKvhekhM4gLEa4ZSo0IT1hOsDndg3ebEbNdZN0gtS%2BL9v4yTWh3qCMIqWSUh9QFJ0BjJjEeM4UwJD2f5J5CCpsYe60cjLU65ynczTl1eMbN5a%2B7wnWcIh2J9denQ0xNR%2FGK2vH368SlDoqknuIaDYAng4UIGy2UobAvqz5wIB7vN1aLfmcxvzoU%2BP1RjWNCK9P6U5yg70GROdABeAXjRQT3RUe09Y2T2QdL%2Bn0aTU%2BdWToCkT0AxFDdg9NwMe%2B1EL5ShJ0pBHsS%2FsfglhGObj3yGrRblmSetzjICK1uhC1nPS%2BN3pmURXO5gGZjD0IUYrLuaVDjek1Z4AHoUKMdZkoDUMmvGLSwSXnCWOiDgC0rMYTa8O8sqChrXKLJSmCR6ScYIiaJ5rDmhqx9wpvgSwLP7QhRYEK%2FYhj2FlkCQmws9EWGWAZkmHy%2FkTsJX%2FutgndM594gKCQSzWDICz0r7PJ5sOjmyd5n8YzeqUyZWGg960ojAI1nmldycoj9UY7GYBu%2FUd7YUgEd2Xt4u86nMKBMvnt85JDZ0DmpLvMnIsr6pxDh2hUA6BNskLhMQgKQkPWp7zy74hvWbabmezQFkJ%2BAXmRtxqiTxnlUUkkNp7cvCkVKCX%2BmNSfYq2mDE9%2BO9rUnSEu%2BLqomXh0cxAa3ZQcCy52wGcqjm1dEaOBDOI1Qam7hcAhDjrI7vke0FNKmdFePX%2FX2RcoY8ReyQLZvLriDrleNL1%2FGC6ufBXH2%2F3saDhPOLz2vcAUMJGj1rU%2BCxOXB3WMw4JlDHk8ggnd9ZBfnQcT%2BNQiHXbe%2Bea7lNGaUBtmgTkxzOHfwAAfx8tHu0tkoArMDENtXay9ljS4NMvcIIcIP5WIpeuQN5xCN2i1PM5zBGWa14KNFpcO%2BPSDewhGFsAFszW3AxIimKnAW6S2Zev4gywMC5soQF8t0aXoRYIk8Mos4p6wNKigjvMWcaxbe6xnSpYKnJ7HaAk8cPZrh%2BxNaitR0x4mC5yO2sHyFzh2axWhota7HHvoraJOeczkHnL00FF6MgaV6%2Fcn0vSq%2Brrtnb3p6eVp2D3CsexvzHuqYLP9V50NCE3u2eW1aa94WHVF9uGBqsZGaFlwvnfVz1tMdwGONWu3ks%2F7OVLNCQX52FBBCOlR5Gy%2FzYxgA07rCQZ6d%2FKYQYB%2FMS0XdMi3wSg6cy%2BXtL650dsdg68HBfz37IFLi24zJ7EBPV3SzYQ%2BcXWopgBwprd8cfyLF%2FFoXsvIWQc3n5SLcst8hqyEho1IKR9bZEyp1L6Fx3sJaRhZEaaRjCglUULJYNb1NJxh%2F3c8iNetj7bOrVGFOZZTjGuET8pgDhwrST2AN%2B9C0AnmeM82uh%2BHbnzcQ08zEVEWS2zafj2knQlYYvvgO7Ej7W1%2BilFos941WR69CTLP0jwiYMMsVfFq4wz4i7WCMGBzcwH7rqVgc4IHVOeBP4fnuY1WQcwnz2suWe2cPSyucNFIakeT3XgDphF4V3AxOuuYR%2FZMKaSoOH4WprAfpmSaeWO51CzPTD1joCLp5kC951zSTC1CO4N56ORDig1iC6uqEFiBoIS37fU%2F%2B9%2F36BT6McLvn8mDMviShDfmic95TbrAmd44VoagmaLzXv9Zs2iAnejPV4uuMbzRMFbZ7n0os6yEE%2F9Zf8QRim%2F%2F%2BMJ9I6z1H%2BxUUBxQS3sa18QHB%2F%2BMz0t4yKA3bKJqf8PtJM%2FYN6JEEh1%2FSf2Gay9iDZ%2FrVvGDkTZ%2FHJH7y2uO0LJrR2%2Bvw0dA7YVWEJW5gyo5TR7HTPO1H1iKMvVzee0KSxyMyfFtGpYaVEUELBq9iS9UpyWp%2BRmBSAHsHLiJpRmmCpmwVA%2BRtN4xEP2NdZlsp8ASRS9bfQ0ODHrPnzt49nSXHs10zdM%2FVmBQ6bO57m1sOygXkRT3Atvgh%2BKkD4IQmE9jZ9pd8gQNbT%2FZwqSjnmn4qcPfO4qxV9R5enwEMeso9fkNdDKQK9H89Pc%2Fr07Td0UtoQitsg9NJk1%2FUljvrlnv4C88dfC324QyoK6SQUMSgScbNVkMmdjttGsmDLb37IMSTF%2BYX85d1VH8KGL6ydgGRadbSx%2Fg3Dq8IY%2BalLscag%2FmbIkPq4oJ09HlATEVwl1mJU1rNNSYojU4F4Q3K4Mwrz2BFP062LclV31EUDTixQKPsI9KGHjbPA9tmKxNNvPZp5r6cFoq405DpgMKWSRJCADg%2BN4ODFkVnD8K1aER%2B8Hk4y7IMk4U1kZeXDTJQ4cpWsZskOCsRI%2BQS6ooRFJ4oXqR9Ca2850lW3IK334QKI2XeZNQ8V9QkIqaeNCnZZTOx3qasvuq2tRxbaVpCyqRcOdJnCqlJP8DikbN5TVDZlmmZMBZs%2BWVCIqLzXyxpmvZKKWdBgdjTJ5u97pmQb5nQO%2B03pccnkNBxHLc9vlpa81JgNkLCBzq1OMGbCb3dP2i0XoHkx54bi9%2B%2BjgRVcRRyTSDt9WzQ8dIZ2ZmwSbEe%2BnK1nxvH01IlfXMnTrHpvPELMl%2FjufyhXsaTwwrCNES%2F6U5FjoonGK9r0iu0VPFqsf5V00izKlCiOJ%2F9kogPdLw4f7f3KAEpe33AgpDop9J4NwGFOo4LvVl9LnjMB%2FFCTo9O%2BH5UQz4zSL%2FMhg6Jwvx4YqiVgpF2%2FrouiYDUyIdmU90807d4GrHOFblcXciHv0xUGe7OIeHM9WjWzNCIBoIs2Ccroen%2FEAshdc4ovQ%2FYeaKtvVCxwHHiMz6%2BCqjg2UOxbbAbNJwhCEvnvxrqOnWO8MC%2BP6GJESISjtpon3GovBtpeuQYPcP%2B1G8EoB35m2vKtLFKyBtHAUY8lkvBYL37%2BzYxuX3w7lqkyAHG2mJie2wprlZkY78z1PQ%2BrHI%2FzpCB1N%2F2swf0v5SLpbGO7TfdW3pRODWoTXI5pWkxKMpBTJ6vU0DXpvy9DgIAcq2kP1CyJsMcuf9YRlIA1NchJYnp81YZN3q%2F%2ByBApI0Z6vX%2FoNn5Z6IqeI9w5vHpHx7BwjqORSxjo8XAa8VAI4jYJhrisnTgwzy3b%2Box1FzOqPVcHlxv59WoKsF6iLvMUVSZCGLOKi95e%2BRQWJgiH2ofLbj50z%2BBxUC7SRj1yPI0CPZuui%2BB2s5rp%2FdU3HM6493zCcgArDGH4luC08K6RgaXk0JkDwePvLzMoM04ct9JOy5x9X91mhJQAbmoZnAeOPKIRsFInMjmUY%2FiOXFJPQMVYDCIjRtQCav4kj3X5%2FnZB4I7xgZVfT8JSREyeY6aDH2XEiDJ9wA9rhW0KWJVVgjKDtvhN3GSX2jCG%2BXQXWbKYmagCROOVJanPCRLSvbNFPLyroPiKxgQSujPDqd6RvpojfolHtHxPkyfluc8bjUXJteJfRj2iWnjphO065dU6tVwCkgU%2FEsnA7cQ140rkMhmX9eaqUYdsq%2FCgghr1OrVaL3x2d4iIrnnizycF2NX%2BHwITQrL%2BOj7NKBys9BVjDBxTSlXUk3hAwYN8JWLDqifC4q1KOwmlRORGrv%2Fzz9LNeyE9e5VY8Ag23DAwTCC2ARIB66o5gJZZ0oZQ7uAcurrMRasswtaRPim8yHbdNa0B98Q%2FlUpc0XUcaDtIZbH8vSy8AaLm%2FA5CLyQb8J0qr55dLJ4iFnfBBTc7ZWBfEbv5VncU%2BzBPG%2BWudu0UCt%2FfcdI%2FM3kN3xjvE0viL90n8Di%2FQUSXEjEr59GQZ2RqWuxPU7UAfQOjf4%2FZ1urDZGyJGjtR%2BWgBrygO8PzzDSxYwZJRrCMYHTAYLripkAn440Mpk7MvPKjTcDV8epDmyrAzHRAkPf71w13tWls%2BZo0AwWjYo4ZtjvXA7IFq4lGPPefQQ6vT%2BTWk3nWCXAhV0gvpIVdGD4nA3dNRKylBtZsQX7GXagO%2FA9wLEv5k2SrI7oC6CVjoCax70ElSqsjjHecumX1PV2yTZBxS047OdLwW04K1eaNDMCUZm17jkKQwellcueQoc1Zmu%2B79c83Wqv%2F9lcTqnr4BdmHj2gLp6qCaoGXB2oqa9wxx6x9BKCX682umHWYc5OMtFdio49QUzfrfjpZzvw6OrcI3uA5GvzQU37BEx6Grxi1YAI2phEYFXmbCBRVxSuktT1z%2BVIqG%2FaxQymhIcZjm6Kwzp4uDDARqBVLDqfKHvzheM0SPmx865MUR2YK8ErEBgmfTiD5Bh8WKNr%2B14kySESWdaKRmg5qSNcoijssVqn7NKgSJSxiORoONCruH7naTr3GCUUYlkXn74TC48GIJBBeCbvLS8VGLkDbyQC%2Bvf5YNS3w7lV8neVcU3%2FwYzg%2BgVwQDTYFStYePxddOYGW4T0tpqzIehky%2FX1UX6jP%2BjbILeDejjbV4%2FEackltSHSA8ueZy0IRjB0g7ljARiFJ5sAxZvfemENO2yMbVOr872R3%2B2kKfpg5UWs8DUSQQPDwjttCkUbYT6Afn3YYqZwce84jHN1W5ZIUcxPWI20X65MTsA7WTXd2mQmKUKcoWAJE6w71tFycOiPl01j8PYjiErIrWQiUcN2a8uOnjSZAz3J8IWLb2Wgv9k%2FSMFn2k9ohXXZiGw4b59bsqDuon5qCDYkrH1Dw9KfTWhRjtTgWe3xLbPIkpU%2B0Hwd97p%2FhPGYTCC6NQhcbOVu9Rj61Eo64HFjvK7WI6WXkBegJp1%2FhfOhLkIQ8%2BgYNP8p0I%2BFTXuk5p3F2LdY56bBXVVe5CGFDVFSSlqrO0KoMasdtCQ%2BTcIS%2BZ1yfS5wl9duw6seog4e1mzpnpQ8iAlIcNbA8fgQNKfbEf2vk3S%2FCP8WOuI6B4mIWz%2BdLhuAtunQ2IH2dm6CJM9WrCw6OpF7vBMo%2BGqc6dZY9zTN8QnyxlxiPnN1b51mTdjppusAv1gc0O7r%2Bjq1bv728xN%2B6yNrJnOjxEHuPP5C7VzkYoMZbMGWvDvS69T05Zkk0i5jEJjO0exVUYmpaTCybqkaboYkBD18czz1Y4NZuMsIH0pPnCafimkwrzk1LZ%2BgaD5Es87gDjs%2BFgxONGhuUjaltLaKYyshmQb%2BqzgOlnMCzeAHatY%2FTpYaynT0CAvsjnxrca36ojEZHrf3MAD%2Bx0ZIwICKecYWSjEU11DXUS5340%2FxfmrU2qUrvbKHG4AnH5CQjDltofnPKfGL5K%2Fzank8Dkhlik7XSuTdNtpa0tUM%2FaZEr4Bz20WjaoMXIxy%2Bgk9zjHVqZoXOSwH2AOJWv4xVJSZFbG0niTUeNLCRnuSQwoEidUNrWUmU8ZS7BD07WpRzEI43OFb7bv2j3Be7kdAv9Be2V3cT9SOvkHMxixp9eNTHivjnzj87B42tZVjXCYs8GORNHkgXCkDsi%2F%2BADJzTXv1qjUUa5EjBOpavHoxicLttoD2Jjxa0%2BIhsG5qKOMEHQvven3OKQC0wlW1dNY9KQ%2F5B4h7Zhy8tF%2BcwUhddunNl4iG%2BNp%2Fsv0mJy7y6LP1vifgdVxBXIwzjaBRRXLCY7TqGrwtIZidu8dNqTRKHg7%2FwcVJW2TxwdGjXi2Le7tzCd6HaLkHeqR2Prx%2Fh59IaG9eQ9q8vp3raEK7mFMeg2lnkuG5PV1nfghiOpM9H8C2Y6QOGEU%2FpF7gz4PoR8Fm699SJjTvJv93UFbB03iyL2zuSJEVYg7ukY0yPueVIqXpj0DH66c3gEGMFoZKXVQmmE9BPaXVSL1Yb6ZaMjm9vIVSVMf4qiiYDlGEipcIx1WTC8nQvYfxDDe5V%2FgQMCh5Tjf0YTfqhGTHegTmCNyeapWQbIYSKC51yUi11txCRbh1PmiqnGR%2BrSUE4Rr%2BuOUhtQ5hkxgwBiKTGSYFF0iwjZtWtpV3855G5Stdonk00h81KkaCsFgk6x7eglcOpFebO8zpSz8CINJgsfBFzRxL2dDEORfJEXQu7vB97gtQTAxiSqmGLpu74r%2F9%2FQdXMxlwvcK2JFdyhhBj1O86xXnwDi0PpX4NDezkB0oLmTzQHMb8d7BnPpEmijcaSbAmpsutM6Uqdz3QV%2FOu2HG94xAgW0M%2BOC6VA0TVnlGRaGYfVKR%2BGIO07HWOqjjclkIPaTyYnhKSdS0zJJEZDKyiVMvoGoar1WjfZMRpdQA%2FafuK4rm5GmZtyUUXa4kDDDE3tMMG%2FcbrCej%2Fm4zduSkvwUpmNDhB3veXg6xYRMfd0zfQHy8mCm7kc54sckM4xLeebjUMiWmQ7YSEPLcC5SG7%2FNIiy7GDzmNLCI3oi8iDXwQEnGj%2FTVMWu2bXfWVUNLazp261ftKVe8xL8ZZcYeJlQ7IYGNGsUAdR82%2BgqkRWW590RZ%2FZrrT5JOq%2B0N7sONfKeTur3XK2VTQ4hpmHx%2FYGj%2F4B8SYOz9HaMfmq4Qngm5o7AK2cNTF8Gn25li7EeCtzyzqE32DcYcU8Dq4Ne8gr3HoP%2B2bulk92rFblkxBcoMyZYAUXRdHAC4mEuhAlNwZ3UH7%2F6aCrQNHZVTRNx059%2B2SXPiqgGkgTCE%2BOGPAGiQEjPTL0EaD7ojA4NYUjb8YKYOPnM57FCyB2dj55sNMRU9Gy5f7%2Fucj6%2B5KF1RYTgoyr28q3ovJU6ePLXkgrG1IgFMz%2FEdV%2FKkmElZbvN5DV5UWw2qlHnQNJePUooTFhArmsuHhyITSvLAHdq6fcTKW1p6aH3znc1Ml6JbxV6hVXNrUoFaQ3sP6RbzhOptKmi7VWFEEmFQvcJ9C8%2BETu6YU4PqQy6kO0vKti2qZYr7hXG2d88ZxdMG%2FE3VHI45h5hao7LxmDTU2ZxrvHsGj%2Fgitf%2ByaJD8pGZCKJ4WBriyfpgRo0hos2gkxgE5%2BJRjt1Lx5TZNAYdvH1zodhf3B9R2ddjnvSVc5Zc%2FQkZ8Ixtj81nAevx%2BRGbF9QBOD1%2BGmngNRpy3O1%2FZt0OPZ2rPwJfutApakOKVmxeRCW5kTa4Yo2F6fh3DaRW%2BFBL9VbJa%2Fnj8tG0RbuPBAcK7o%2BawLrW6JxpkyN%2BMGwoiXkrBn%2FE8NfvS6O0xsmxtbOWABlbpTY3uDjk%2BEtUndGq2iNA95mTcM5WMKmhEk%2FcTyKVlK1dsi5oUVwqCAsoM2qasUigbU3Cycar85mt6iHkUP7VstHl5MwXLp1u4W5HiNl4BVhrual9cHhoAp4DbwIQPpaqa3VcKiLVUbyYSVhgvFTrqYzQeRMz9IJiGDB9Oj8u%2FQFtW7zWGAbilPadp2el3S8wj58Q5v1qHulBWkb0NZJV7whx0YYrEmQw4xxB8wvSgbZmkNMHRFpc6rZjckj30SnBAlfzxTJZ18HtEvrfAr0rpv%2FitROeL6Z5Cdmt2ZaeoyJ%2FQmhhGE77GHwr6oHUh%2FA0R6CseoGAzMHzdROjaRKZGcitkHY07fV4lUP4sRx0o%2FXxFOqSd9Xw8NyXTZmjMGbHCxk617J9PwDQslaaJQW5bYnsyBkkphXwuSVVHesh36eu5JBgdLaCj32qu%2BiZmlR5IbTu5HkVHmF6rvBb60ihNIfres%2FNj%2BFZFuqP0oHSV%2FhKHu5Y402PcPUOy4fYqS5iG4NdLmnY5u45%2B3%2Fi1NQEU41FHQkYXu0dVRmSHplMrlVJTaK7ow%2BMZbVO8hGNzLdkstvdeC35vJGPD3BtrXCuHeo0HwcaUoQ0ZGYF8FtCMVRfTDfupjgTBtIN%2B%2BBEdt%2FvHh1lR8qgRLeF%2FuzPnxlMxwIR3TswN5r2DESB6sNth%2B5O1aqfN8gOid3XRg6RibsgMoFicNQ3s6EEyOB7v45RipYBSQQqXQYtY1Hdgt5gBlKw7Vac6UQ2XQYmRiYO7giqSutcH2hjq%2FHl8NZDP32y9i6VY4mZmCC2aLLZ54qRy9bdYpNTUImhBBpF32%2B3C3Rfwa7Ma4NboUppq%2FElVfp50ZNYSkbVEstjAWc2ZdDVgn%2BTPbVp4tLg0VGwMfMCI4gb%2B5V%2FLl%2BriV1k%2BN8%2FL%2BK1bfyBHbAaP8lGJVr8s%2BWOWm2hY5f0skELL1kTBTw9om8KYygI3mQLWaXLvgqlAyFRBkIJhbw5BJRU1q2%2FvI6C5lKScxjrB4PbXFxp6jXixCNa60DrOWQr56DLOyuplex9ej9T5Tj3OAZ30Si2K17hJTi9l4KwT4hsGU%2B1fiYtdhkFCrafeUC5cPhkg2RDKNxqbBWPpPW9Fux1JcmSBAEPyiCg%2BWylQgfyZSn5MXapT5GoQ8XHfBOfIpKBN3dT43vJoRlkmiZnwiHilSA3u9lxRFqkn%2FURjK8ho1sR8UYgz3CrOce6%2BZ5KI71jbk%2BNTsFt%2BOBgr4SvNrJVQsEgxwAWbAxleU%2FG%2Biv68Y5bZC9TuAwcuwuzAQWR%2B9poapwvO0N14oa9%2FqY0SHszbED808vXFxEHuDLBu92SZvOjECOpCiTkuL6LAGXEepRsGpFKo1pp%2FI6edP4AWMB7%2F3PlORS2KCctNBHyWw19UI0qri9%2BP80ych400Fub990ZQFN4sGrigG%2FeiPLEkYT%2BHW4vURE554a3rvyA67LNPmijCJYWWmR7hFvwk%2FZ4rkmIFiTetvefuq1G%2FyPhT8u5eIvNcRsdTgi%2B0a1bhmg7ZT7ngMGEtf7RzhEUVlqhMRzxxe%2FQp1HtL5ItdMJeRYXWYxPt4WgYrb%2B34ms0LcJ%2FET3xc4eNcyeHsSqXdek%2B9D6gRFfyRrMUktXqwkAuQ1lwVIIAbKMzeXtl73rtMCXeyxrWe11I1q7KteO%2BdbpmbSbe%2FQCszlPQKNdR%2BTgf3vsOiJPqzGYMXsCU49rsAtt185JxAgMZHytmLsFpkpiQl4WkutYnMFWNySCzvrznarNoLWTbbirmvrq26Q%2BtSZJ9YE6B4s9TebBJj%2B4dn%2F8jcjbyZVlIzyUs9unmYMVDuSZDxXIUTcARjnTRL0Dnwx4F%2BAuXr1CTRtk04py3d9PeBW7deyK2hJUsZTKP7K5GSntnSkCofOftRYJIVkmMpe2fsDQm5njJbkJ%2FzTXMMW4Df4%2FGDZTUwYXS5mpBgV7tXgK78kvU0Pk7T1UTHBDhrgWEqGrJ9ye66UYUbgT68Eo7WM3bDaf9BlGk6kBA%2FWbjwwVQ5R%2FjU955KadJCLV03IHRvHcGc5oSp%2BvV0Obxfs8DCIqkfgx%2BDXknymatWD1bpI1ZChk66QQRzeZvpP67fSD5KWyQmSho0OZdUA%2FVnyOw3JaYWPYXEGXlUEaMEUjhHbawzriNsDTU1MI2GkRmnsugJeIqciKscf0RAUV%2Fwkp1wBOqHLWiJHAKMIng6rT7tvFvF8B2t%2FP6Mxd8zjlx7mpP8s5abh1ElZKU%2Fi0nBfNK9VtBu88dbfGCRGwwY6%2FZhCKEjAy%2BQmmyauWYiOpYzW9fWrgWEAgmDEoAeiR1h7pQo6QQk%2BtlQFOhZeUj7neJ%2Bl8IGOmjBPLgIofs8jD772D7lkbCMUI1SuOZJpYVS77F%2BPY4J4375I%2FpUkDoe5QOWYWgrwyQXqhalOCCLqt4vlIaCSDgBcC9Nl2T3akJKAcS2%2FCiKqn7H9iMkFk5QC02%2FeX91uk%2BkZT1Ldo%2BGwaBgObaLlhsw56j%2FQhI3CX%2F%2F7iSv1lbEb%2Bj48XTYho1pLqk9Z3mLeaFjSweqKHxg5KB1AVvBN6HHwjL7WCNnLtTuOcx0v8drBU5pczIRztiRc4yJqm3C4civFTos5sxtReYG0qZMUa5DBf4z95eTGMx0SbtqoY6eZ2xubw2C9X3pV5ZKqlVJmrX5RyUCxYZ%2FGXgwA3ccF38Egzk4oTCr8wDO9giQnceIRFlg9JsFfG3fQdFXi7p4AkjChB679JCdDTJQCUrGtKOwveinx42HOId4PJFPH5Te65qRnbpQW62%2B0m%2F%2FXU6Dp%2BurAYLWckQgyUuLJLzHD7ND1JROZZoo%2BX4ViOGPA3CfbKuOEa%2B0kmdIpdYOI7t2uJhG136y%2FjjB%2Foh8nmfz5KogBkt%2Bmhg%2FQtbH4iurFzqctVjtIZrLup5aME9s15NSWnwZnTluj0YObRpX2kJXmbN4OB68QwfRoqvjsJMJNGj91MuvKOZp6GkJblpIMflpgbI9R8ElQnpyfQeSeU%2BbTi5rIGZB0EvKID5rS%2BOEVLYhhPSCOtHAha%2Bk%2BBdVDpZNpG%2BFHeiW1tMQAnNlDyANoPMNuEFRPKaf2TbAA6SATNo7RWH%2FZqrpYGF0IkwT7UEd5fmZ6paIw%2BSGlbNT0HvV22gQUlmWa4sAglOhReDyJAzL4tycF6s5VP91GR%2FzBdCuxFKdGpYQDEP%2F%2FIFQQjGO8qx5erCGO%2BdW1WVe03OMsRUA0oLkgE2s7V3sRC7vIkdLmNTCpcOzU2DtKEKDqeaNFIlKhaxmiV00dNFJWB8tAHdHLDTJSIacEj%2FHKrk0QOQZKVp1NlvFC4FThtYElcNlajzywp%2Fe2mf%2FGKKyloS8nL8bNsFe4quqcAjENCb0Ho5Dj83nMte%2B32JoqzWUs7Hj8%2FbYsXBlbMaXmto4XDcihSPB9AvolBrSg14p1pmZlEDHyuMB1c4HoHJkSnK5DlSL55UELCmg98tmY2m1hmOXt%2FinmM%2FWqfgDg%2ByiJTIi4Y7km7l%2FMY%2FhCEnmvnlPTekjIf1qC0ZfLF%2B1dFUBOvqMz9GI1KiamUHOLJxqDF8oh5VSN9rPiRo5WAVyCBb%2BF%2BQPu5QY%2BCc97JcUmE%2Bf%2BNJQiJWP%2FGNqqPSTcBAL%2F8LP6G7nuQzB5E01oB%2BUY7WdeTPQQmy%2B5AWAG8g5HkeBUK8vvbTf3Z%2FnYtG2ob3DLYuFvaECsz1L57rRU2tYfU9uUMRjNC1DPEipZOKoRRPzlkaApY7HcypJY58qgYcx%2BkRkdsBTKp6dmuMSlYXGgMjA76oC1Ut3t92h8SF1gFVuntDK%2F3FeFT8KINLSyZff96hV2lI6xlGp%2F7dpXWifK2zWqNR3SumKHrUy8JIbUKxft1DACI%2Bw48NimL6chYiTIpTZIn3iDt73VuXeasRbOA8NIAE%2BC3X%2FG63%2F511LDRHVR2eJa2VDUkVYrwVnxKmpLpfCTlgfcrAgLmIp0DAqA%2FqDyXi9pd2R5f1%2F6kXAUTTzB1WJVKsgPIAwVp93m7Pc0kS6wrltIwTbAhjLOmsaLsPIjI%2FfrdIft0nnE5pF0dMPZQijI6cVJ6GoZ9G9ZaIX0eJ1Rc7MV2a32cc7yRcWg7Gh59r8OrtZnz%2Fqc3gW2lWi51xfpoVzX%2BMYnAy3%2F7QeB01m0Eif8PotzcImj4NgVZnB508uYFtbER6i0HX2ndNEnqE%2BO%2B5JoNhJ9mBEgukxHC4lOcDPkzyn02BQbr6e8z4PYZWyiNayxyBVjUE1XnsKVkdyd%2FMKuoIaYqhucLYwpiXM9lQBgoYuNtGhmGdbaYFB05rmi74cYLtebwrcvyX%2Brj12g%2FjlwsC4PCbPwh3D1BpzMjHMsQhSKnctyLx42M9L0B9ciIoShXDC%2BXHx31A6WftqMq5rsvKJ7%2F0N8ubarduhekxA93s8gTc%2BLDnG9HZ4mLZODMApYWJJTGM1IL%2FBJ%2F1d6ELiOdRhiy%2BQ1VqU44dnziswuw%2FhK2IzIiAqtFxm%2BtmGo33MHBzxu06YvqBKOZndFp6CYoco3Soqrl8nxOJq9Cb%2BurzAF7ci3X97DO10T4P5xuqZZpyWP3mScDjxgRLMo2vmrNhORTkUfdIv%2B4wtSWtdQyQ%2FArhq01sZ4KDbUz1KQhjfeZNpyQf%2BLY0e7gozN1JmhgL9wC0QsCsRKteJa45qqKSmvDoyuZwFpnPFxo%2F1VP4Nz%2BLl9eGiilJnTLAOLBP5Q1va8pFby76cij1IwUKakQ%2B6nr7yhXBETobWpNCP3flezVr8z2w3mZduY2GBzfWItsGqnhv%2FFpskZsq%2BLqOuTltIAWzWPn%2FL%2FgAZPoqX%2Bn60yj6OeRDnesf6NB%2FPThsYNpKAYEP7wIkpljikBmc7ugFCIFpV9epN%2FTXM5ccFhvWvPR64H%2BRJd%2BWj0wrU4z7xOOznSthciu3%2F6TBK9BB71MEaAuhiWK3DH%2FNwNGbShA4P1CWn9xf1ArGl8PXHTk8iQQGM8qR8Bi1B42YkfaCaHiOq4CShcMe%2BMn9mLySXZiwDCqAYNUclZQKt%2BdYZ3ViVN6ivIkMNVgDFXc7O1lbXlI0UIViMWt1Avlb8%2B3lNbG1jWqjF2WiTom3dX4%2Be%2BNYqsDpaC%2FpDg2pwHu89vyIUsQa7Gx3NQLWLNzfdZEwURjk6kie%2F3GGtiVGUIKx82pY2ZExOz7oEzeF3jnAyLD62HAdv8fAXRNPEOP8YKm8ye%2FO6aZG9UpyOFQIVWCbpYAQrm7iU8db2NXm61FEWvY83JpahPSJr58gbqCp9sCr%2F7YdVe%2FjbRONtFu1PD07w8WUZSEpmm6KkIe2RrfN1gZxJKKGiCjnQAdBe3oIASxn30%2FpPBKlHbS5t1jTZiGuDHdtnPKelIa6CtNwBhA7vPcFcVLLBBbCR%2FkOz6j%2Bnr5uAwac0KxaM4a7uhoi1ayhwUjlqhchGtV81P1a57S9ma8twPS%2BoqmTZruRRiapZOQLbv41FXv89gVcmqH0AZcFipPahK35nRyeBvSCyrYt39N%2Fb3jjPdkoZOwxkJc7VAQPVjHl7RpHrOflW1CVzyMqdWBExmHgafPsCF3UKjZeNaCZj0TzIrJI48%2BvB1ZCOHtXaECU6mVuAUuqWgmGsPskg1vkIcWRBvEyslN73uY5xwS38QCV7xnEbdlPJDlXkc8BZLgvnLLlFTp2GNecMn51RnnsFS4KB1aDLmDGk%2BZVV4ZFbLoo86gDwqyFvJ65iK443FPIscmykE2Ye6kkUFrJC6Jse69YqWzcOhFei1CGVLdDZTlGMszcYaQ5DmrPJiAP2Ojm61AnCWKLXCkMEvPRNRX1rQHGjNjtoUjDapbtmNXOhqDrNsVGksAWmT22nZqBLorpwMtINZhD5xJwEm2XMmcTakhLPhuIaO04nq%2F9Yoa1w6j02BKr8zsAhFMRXox0uq%2B5GLjSXjXztcIiQdxBWCl7UajIUbyoeEQFWsc%2FmMtj4BW8T%2F9NM0LqV9dFhXIBBwo1bZwnC8rdAHT2qD5HHcyNpNcmOfrW%2FDOy4je96tbhE2TjzHN9gcnWsbsIQvX%2Fi2rzR%2FLkFrGZPOkT1gKbSnx7%2F3VoiVtngGqsVE2fzFwpCmTvXlKsmGs6xThzMUaYBY0a3PipCaPEWLynmo2IeGHih%2B5rI95YLNwAk8SvDSd4zxygwaegenuGaYv6xebLF9BHRRuxTdlT9Ps%2B7ELuxGhNVIVa%2BtipUi9N40F8p1K3XzHDKD%2FcdKMhgSFNbK%2BGeclT9KIoq4LPxbmzT%2BGKRJ4Yxl%2FRVgxTi9Gl%2BACg%3D%3D&__VIEWSTATEGENERATOR=C2EE9ABB&__VIEWSTATEENCRYPTED=&__EVENTVALIDATION=P0ldEyCXyRIx4S1aexEbtjI40PGbrukNkS1tPWHWZHDs1BueEG%2B6vUxTALDU6zKoH5MfPzTbOi%2FxfNyOHNm88MDf7FMJbDc%2BqTIbb9xGfLjXAH4tjJjIOAxxKc7yzAnLIBlAJlrdlLVHLB2FDWcoUfE2Ll%2FmoRTEKdPfDFbcbgC2sAbjkGGDz9cPqQeOyZmlVCDVrHN6DocxsbFkljofVLxf2ojXRlFu%2F7tj1rB20c0Tfal2Oopjp7Fd%2FOBGinTqCIXyaVKCn4FcerfQdDLoKCVOKytg0NDVc%2BmH%2B8ENdQdDf2AdeO3Dp03ljNzXBENEBCrXtnBrw%2B92RzPYoOQqI8h7LS9xo3ojce7T1tKkCHFZm8GiZhvOkYTPi11shdDkkX4%2FwXh977vMyWhhW%2F7QwQ9bumdZ5D1MZ4slOaIZnxNHPWBCVjtSXfKLCzRdPfhGCDbQusGQoC4UII9%2F33A1eQsOXDFExBZM6OHycqywRBJr8FjFtt%2FkEnI1W6QLrGepz%2FYtbS%2F48l1xe1VQbuJP%2B3AI0YU%3D&ctl00%24hdnUnsavedDataWarningEnabled=false&ctl00%24hdnHorizontalScrollPosition=&ctl00%24hdnVerticalScrollPosition=&ctl00%24hdnStaffRegisterInFlag=in&ctl00%24PageContent%24loginControl%24hdnMaxLoginAttempts=0&ctl00%24PageContent%24loginControl%24hdnToken=&ctl00%24PageContent%24loginControl%24hdnLinkAccount=0&ctl00%24PageContent%24loginControl%24hdnIsPWALogin=false&ctl00%24PageContent%24loginControl%24hdnIsPupilPortal=0&ctl00%24PageContent%24loginControl%24languageSelect%24ddlLanguage=UK+English&ctl00_PageContent_loginControl_languageSelect_ddlLanguage_ClientState=&ctl00%24PageContent%24loginControl%24txtUN={{USERNAME}}&ctl00%24PageContent%24loginControl%24txtPwd={{PASSWORD}}&ctl00%24PageContent%24loginControl%24txtMFA=&ctl00%24PageContent%24loginControl%24btnLogin=Login&ctl00%24ddlReason=Select&ctl00_ddlReason_ClientState=&ctl00%24txtNotes= \ No newline at end of file diff --git a/engage-api/struct-activity.ts b/engage-api/struct-activity.ts new file mode 100644 index 0000000..3151865 --- /dev/null +++ b/engage-api/struct-activity.ts @@ -0,0 +1,242 @@ +// engage-api/struct-activity.ts +import pangu from 'pangu'; +import { logger } from '../utils/logger'; +import type { ActivityData } from '../models/activity'; + +// Define interfaces +interface ActivityField { + fID: string; + fData: string; + fType?: string; + lParms?: any[]; +} + +interface ActivityRow { + rID: string; + fields: ActivityField[]; +} + +interface RawActivityData { + newRows: ActivityRow[]; + isError?: boolean; +} + +interface Location { + block: string | null; + room: string | null; + site: string | null; +} + +interface Duration { + endDate: string | null; + isRecurringWeekly: boolean | null; + startDate: string | null; +} + +interface Grades { + max: string | null; + min: string | null; +} + +interface Meeting { + day: string | null; + endTime: string | null; + location: Location; + startTime: string | null; +} + +const clubSchema: ActivityData = { + academicYear: null, + category: null, + description: null, + duration: { + endDate: null, + isRecurringWeekly: null, + startDate: null + }, + grades: { + max: null, + min: null + }, + id: null, + isPreSignup: null, + isStudentLed: null, + materials: [], + meeting: { + day: null, + endTime: null, + location: { + block: null, + room: null, + site: null + }, + startTime: null + }, + name: null, + photo: null, + poorWeatherPlan: null, + requirements: [], + schedule: null, + semesterCost: null, + staff: [], + staffForReports: [], + studentLeaders: [] +}; + +async function applyFields(field: ActivityField, structuredActivityData: ActivityData): Promise { + switch (true) { + case field.fID === "academicyear": + structuredActivityData.academicYear = field.fData; + break; + case field.fID === "schedule": + structuredActivityData.schedule = field.fData; + break; + case field.fID === "category": + structuredActivityData.category = field.fData; + break; + case field.fID === "activityname": + if (!structuredActivityData.name) structuredActivityData.name = ""; + structuredActivityData.name = field.fData.replaceAll(" "," "); + structuredActivityData.name = structuredActivityData.name.replaceAll(")", ")"); + structuredActivityData.name = structuredActivityData.name.replaceAll("(", "("); + structuredActivityData.name = structuredActivityData.name.replaceAll("'", "'"); + structuredActivityData.name = structuredActivityData.name.replaceAll(".", ""); + structuredActivityData.name = structuredActivityData.name.replaceAll("IssuesT台上的社会问题", "Issues T 台上的社会问题"); + structuredActivityData.name = + structuredActivityData.name.replaceAll("校管弦乐团(新老成员都适用", "校管弦乐团(新老成员都适用)").replaceAll("))",")"); + structuredActivityData.name = pangu.spacing(structuredActivityData.name); + break; + case field.fID === "day": + structuredActivityData.meeting.day = field.fData; + break; + case field.fID === "start": + structuredActivityData.meeting.startTime = field.fData; + break; + case field.fID === "end": + structuredActivityData.meeting.endTime = field.fData; + break; + case field.fID === "site": + structuredActivityData.meeting.location.site = field.fData; + break; + case field.fID === "block": + structuredActivityData.meeting.location.block = field.fData; + break; + case field.fID === "room": + structuredActivityData.meeting.location.room = field.fData; + break; + case field.fID === "staff": + let staff = field.fData.split(", "); + structuredActivityData.staff = staff; + break; + case field.fID === "runsfrom": + structuredActivityData.duration.startDate = field.fData; + break; + case field.fID === "runsto": + structuredActivityData.duration.endDate = field.fData; + break; + case field.fData === "Recurring Weekly": + structuredActivityData.duration.isRecurringWeekly = true; + break; + default: + logger.debug(`No matching case for field: fID=${field.fID}, fType=${field.fType}`); + break; + } +} + +async function postProcess(structuredActivityData: ActivityData): Promise { + // Format description + structuredActivityData.description = structuredActivityData.description?.replaceAll("
", "\n") ?? ""; + structuredActivityData.description = structuredActivityData.description?.replaceAll("\u000B", "\v") ?? ""; + structuredActivityData.description = pangu.spacing(structuredActivityData.description ?? ""); + structuredActivityData.description = structuredActivityData.description?.replaceAll("\n ", "\n") ?? ""; + + // Determine if student-led + if (structuredActivityData.name) { + if (structuredActivityData.name.search("Student-led") !== -1 || + structuredActivityData.name.search("学生社团") !== -1 || + structuredActivityData.name.search("(SL)") !== -1) { + structuredActivityData.isStudentLed = true; + } else { + structuredActivityData.isStudentLed = false; + } + } + + // Parse grades from schedule + try { + if (structuredActivityData.schedule) { + let grades = structuredActivityData.schedule.match(/G(\d+)-(\d+)/) || + structuredActivityData.schedule.match(/KG(\d+)-KG(\d+)/); + + if (!grades || grades.length < 3) { + throw new Error('Invalid grade format in schedule'); + } + const minGrade = grades[1]; + const maxGrade = grades[2]; + if (minGrade === undefined || maxGrade === undefined) { + throw new Error('Invalid grade format in schedule'); + } + structuredActivityData.grades.min = parseInt(minGrade).toString(10); + structuredActivityData.grades.max = parseInt(maxGrade).toString(10); + } + } catch (error) { + logger.error(`Failed to parse grades: ${(error as Error).message}`); + structuredActivityData.grades.min = null; + structuredActivityData.grades.max = null; + } +} + +export async function structActivityData(rawActivityData: RawActivityData): Promise { + let structuredActivityData: ActivityData = JSON.parse(JSON.stringify(clubSchema)); + let rows = rawActivityData.newRows; + + // Load club id - "rID": "3350:1:0:0" + structuredActivityData.id = rows[0]?.rID?.split(":")[0] ?? null; + + for (const rowObject of rows) { + for (let i = 0; i < rowObject.fields.length; i++) { + const field = rowObject.fields[i]; + // Skip if no fData + if (!field || !field.fData) { continue; } + // Process hard cases first + if (field.fData === "Description") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + structuredActivityData.description = rowObject.fields[i + 1].fData; + } + continue; + } else if (field.fData === "Name To Appear On Reports") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + let staffForReports = rowObject.fields[i + 1].fData.split(", "); + structuredActivityData.staffForReports = staffForReports; + } + } else if (field.fData === "Upload Photo") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + structuredActivityData.photo = rowObject.fields[i + 1].fData; + } + } else if (field.fData === "Poor Weather Plan") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + structuredActivityData.poorWeatherPlan = rowObject.fields[i + 1].fData; + } + } else if (field.fData === "Activity Runs From") { + if (i + 4 < rowObject.fields.length && rowObject.fields[i + 4]) { + structuredActivityData.duration.isRecurringWeekly = + rowObject.fields[i + 4].fData === "Recurring Weekly"; + } + } else if (field.fData === "Is Pre Sign-up") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + structuredActivityData.isPreSignup = rowObject.fields[i + 1].fData !== ""; + } + } else if (field.fData === "Semester Cost") { + if (i + 1 < rowObject.fields.length && rowObject.fields[i + 1]) { + structuredActivityData.semesterCost = + rowObject.fields[i + 1].fData === "" ? null : rowObject.fields[i + 1].fData; + } + } else { + // Pass any other easy cases to helper function + await applyFields(field, structuredActivityData); + } + } + } + + await postProcess(structuredActivityData); + return structuredActivityData; +} diff --git a/engage-api/struct-staff.ts b/engage-api/struct-staff.ts new file mode 100644 index 0000000..e70dd48 --- /dev/null +++ b/engage-api/struct-staff.ts @@ -0,0 +1,114 @@ +// ./engage-api/struct-staff.ts +import { logger } from '../utils/logger'; + +// Define interfaces +interface Staff { + key: string; + val: string; +} + +interface ActivityField { + fID: string; + fData?: string; + lParms?: Staff[]; +} + +interface ActivityRow { + fields: ActivityField[]; +} + +interface RawActivityData { + newRows: ActivityRow[]; +} + +// Using a Map for staff data +const staffs: Map = new Map(); + +/** + * Filters out blacklisted staff keys and corrects odd name formats + * @param staffsMap - Map of staff IDs to names + * @returns Cleaned staff map + */ +async function dropOddName(staffsMap: Map): Promise> { + const blackList: string[] = [ + "CL1-827", "CL1-831", "ID: CL1-832", "CL1-834", + "CL1-835", "CL1-836", "CL1-838", "CL1-842", "CL1-843", + "CL1-844", "CL1-845", "CL1-846" + ]; + + const oddNames: Record = { + "Mr TT15 Pri KinLiu TT15 Pri KinLiu": "Mr Kin Liu", + "Mr TT13 Yanni Shen TT13 Yanni Shen": "Mr Yanni Shen", + "Mr TT19 Pri Saima Salem TT19 Pri Saima Salem": "Mr Saima Salem", + "Ms TT Ca(CCA) TT Ma": "Ms Ca Ma", + "Mr JackyT JackyT": "Mr JackyT", + "Ms TT Ma TT M": "Ms Ma M", + "TT01 Fang TT01 Dong": "Mr Fang Dong", + "Mr TT18 Shane Rose TT18 Shane Rose": "Mr Shane Rose", + "Ms Caroline Malone(id)": "Ms Caroline Malone", + "Ms Marina Mao(id)": "Ms Marina Mao", + "Mrs Amy Yuan (Lower Secondary Secretary初中部学部助理)": "Mrs Amy Yuan", + "Ms Lily Liu (Primary)": "Ms Lily Liu", + "Ms Cindy 薛": "Ms Cindy Xue", + "Ms SiSi Li": "Ms Sisi Li" + }; + + // Filter out blacklisted keys + for (const key of blackList) { + staffsMap.delete(key); + } + + // Update odd names + for (const [originalName, correctedName] of Object.entries(oddNames)) { + for (const [id, name] of staffsMap.entries()) { + if (name === originalName) { + staffsMap.set(id, correctedName); + } + } + } + + return staffsMap; +} + +/** + * Updates the staff map with new staff data + * @param staffsMap - Existing map of staff IDs to names + * @param lParms - Array of staff objects with key/value pairs + * @returns Updated staff map + */ +async function updateStaffMap( + staffsMap: Map, + lParms?: Staff[] +): Promise> { + if (!lParms) { + return staffsMap; + } + + for (const staff of lParms) { + if (staff && staff.key) { + staffsMap.set(staff.key, staff.val || ""); + } + } + + return await dropOddName(staffsMap); +} + +/** + * Structures staff data from raw activity data + * @param rawActivityData - Raw activity data from API + * @returns Map of staff IDs to names + */ +export async function structStaffData(rawActivityData: RawActivityData): Promise> { + const rows = rawActivityData.newRows; + + for (const rowObject of rows) { + for (const field of rowObject.fields) { + if (field.fID === "staff") { + return await updateStaffMap(staffs, field.lParms); + } + } + } + + // Return the staff map even if no updates were made + return staffs; +} diff --git a/example.env b/example.env new file mode 100644 index 0000000..45498ac --- /dev/null +++ b/example.env @@ -0,0 +1,18 @@ +API_USERNAME= +API_PASSWORD= +PORT=3000 +FIXED_STAFF_ACTIVITY_ID=7095 +ALLOWED_ORIGINS=* +S3_ENDPOINT= +S3_BUCKET_NAME= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_REGION= +S3_PUBLIC_URL_PREFIX=files +REDIS_URL=redis://:dsas-cca@redis:6379 +MIN_ACTIVITY_ID_SCAN=3000 +MAX_ACTIVITY_ID_SCAN=8000 +CONCURRENT_API_CALLS=16 +STAFF_UPDATE_INTERVAL_MINS=360 +CLUB_UPDATE_INTERVAL_MINS=360 +LOG_LEVEL=info # Example: 'debug', 'info', 'warn', 'error' diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..8301695 --- /dev/null +++ b/index.ts @@ -0,0 +1,314 @@ +// index.ts +import express, { Request, Response } from 'express'; +import { config } from 'dotenv'; +import cors from 'cors'; +import { fetchActivityData } from './engage-api/get-activity'; +import { structActivityData } from './engage-api/struct-activity'; +import { structStaffData } from './engage-api/struct-staff'; +import { + getActivityData, + setActivityData, + getStaffData, + setStaffData, + getRedisClient, + getAllActivityKeys, + ACTIVITY_KEY_PREFIX, + closeRedisConnection +} from './services/redis-service'; +import { uploadImageFromBase64 } from './services/s3-service'; +import { extractBase64Image } from './utils/image-processor'; +import { + initializeClubCache, + updateStaleClubs, + initializeOrUpdateStaffCache, + cleanupOrphanedS3Images +} from './services/cache-manager'; +import { logger } from './utils/logger'; + +// Define interfaces for our data structures +interface ActivityData { + id?: string; + name?: string; + photo?: string; + lastCheck?: string; + source?: string; + error?: string; + cache?: string; + [key: string]: any; +} + +interface StaffData { + lastCheck?: string; + cache?: string; + [key: string]: any; +} + +interface ImageInfo { + base64Content: string; + format: string; +} + +interface ProcessedActivityResult { + data: ActivityData; + status: number; +} + +config(); + +const USERNAME = process.env.API_USERNAME; +const PASSWORD = process.env.API_PASSWORD; +const PORT = process.env.PORT || 3000; +const FIXED_STAFF_ACTIVITY_ID = process.env.FIXED_STAFF_ACTIVITY_ID; +const allowedOriginsEnv = process.env.ALLOWED_ORIGINS || '*'; +const CLUB_CHECK_INTERVAL_SECONDS = parseInt(process.env.CLUB_CHECK_INTERVAL_SECONDS || '300', 10); +const STAFF_CHECK_INTERVAL_SECONDS = parseInt(process.env.STAFF_CHECK_INTERVAL_SECONDS || '300', 10); + +// CORS configuration +type CorsOptions = { + origin: string | string[] | ((origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => void); +}; + +let corsOptions: CorsOptions; +if (allowedOriginsEnv === '*') { + corsOptions = { origin: '*' }; +} else { + const originsArray = allowedOriginsEnv.split(',').map(origin => origin.trim()); + corsOptions = { + origin: function (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) { + if (!origin || originsArray.indexOf(origin) !== -1 || originsArray.includes('*')) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + } + }; +} + +const app = express(); +app.use(cors(corsOptions)); +app.use(express.json()); + +// Helper function to process activity data (fetch, struct, S3, cache) for API calls +async function fetchProcessAndStoreActivity(activityId: string): Promise { + logger.info(`API call: Cache miss or forced fetch for activity ID: ${activityId}.`); + const activityJson = await fetchActivityData(activityId, USERNAME as string, PASSWORD as string); + + if (!activityJson) { + logger.warn(`API call: No data from engage API for activity ${activityId}. Caching as empty.`); + const emptyData: ActivityData = { lastCheck: new Date().toISOString(), source: 'api-fetch-empty' }; + await setActivityData(activityId, emptyData); + return { data: emptyData, status: 404 }; + } + + let structuredActivity = await structActivityData(activityJson); + if (structuredActivity && structuredActivity.photo && + typeof structuredActivity.photo === 'string' && + structuredActivity.photo.startsWith('data:image')) { + + const imageInfo = extractBase64Image(structuredActivity.photo) as ImageInfo | null; + if (imageInfo) { + const s3Url = await uploadImageFromBase64(imageInfo.base64Content, imageInfo.format, activityId); + if (s3Url) { + structuredActivity.photo = s3Url; + } else { + logger.warn(`API call: Failed S3 upload for activity ${activityId}. Photo may be base64 or null.`); + } + } + } + structuredActivity.lastCheck = new Date().toISOString(); + await setActivityData(activityId, structuredActivity); + return { data: structuredActivity, status: 200 }; +} + +// --- API Endpoints --- +app.get('/', (_req: Request, res: Response) => { + res.send('Welcome to the DSAS CCA API!

\ + API Endpoints:
\ + GET /v1/activity/list
\ + GET /v1/activity/:activityId (ID must be 1-4 digits)
\ + GET /v1/staffs'); +}); + +// Activity list endpoint +app.get('/v1/activity/list', async (_req: Request, res: Response) => { + try { + logger.info('Request received for /v1/activity/list'); + const activityKeys = await getAllActivityKeys(); + const clubList: Record = {}; + + if (!activityKeys || activityKeys.length === 0) { + logger.info('No activity keys found in Redis for list.'); + return res.json({}); + } + + // Fetch all activity data in parallel + const allActivityDataPromises = activityKeys.map(async (key) => { + const activityId = key.substring(ACTIVITY_KEY_PREFIX.length); + return getActivityData(activityId); + }); + + const allActivities = await Promise.all(allActivityDataPromises); + + allActivities.forEach((activityData: ActivityData | null) => { + if (activityData && + activityData.id && + activityData.name && + !activityData.error && + activityData.source !== 'api-fetch-empty') { + clubList[activityData.id] = activityData.name; + } + }); + + logger.info(`Returning list of ${Object.keys(clubList).length} valid clubs.`); + res.json(clubList); + + } catch (error) { + logger.error('Error in /v1/activity/list endpoint:', error); + res.status(500).json({ error: 'An internal server error occurred while generating activity list.' }); + } +}); + +// Single activity endpoint +app.get('/v1/activity/:activityId', async (req: Request, res: Response) => { + const { activityId } = req.params; + + if (!/^\d{1,4}$/.test(activityId)) { + return res.status(400).json({ error: 'Invalid Activity ID format.' }); + } + if (!USERNAME || !PASSWORD) { + logger.error('API username or password not configured.'); + return res.status(500).json({ error: 'Server configuration error.' }); + } + + try { + let cachedActivity = await getActivityData(activityId); + const isValidCacheEntry = cachedActivity && + !cachedActivity.error && + Object.keys(cachedActivity).filter(k => k !== 'lastCheck' && k !== 'cache' && k !== 'source').length > 0; + + if (isValidCacheEntry) { + logger.info(`Cache HIT for activity ID: ${activityId}`); + cachedActivity.cache = "HIT"; + return res.json(cachedActivity); + } + + logger.info(`Cache MISS or stale/empty for activity ID: ${activityId}. Fetching...`); + const { data: liveActivity, status } = await fetchProcessAndStoreActivity(activityId); + + liveActivity.cache = "MISS"; + if (status === 404 && Object.keys(liveActivity).filter(k => k !== 'lastCheck' && k !== 'cache' && k !== 'source').length === 0) { + return res.status(404).json({ error: `Activity ${activityId} not found.`, ...liveActivity }); + } + res.status(status).json(liveActivity); + + } catch (error) { + logger.error(`Error in /v1/activity/${activityId} endpoint:`, error); + res.status(500).json({ error: 'An internal server error occurred.', cache: "ERROR" }); + } +}); + +// Staff endpoint +app.get('/v1/staffs', async (_req: Request, res: Response) => { + if (!USERNAME || !PASSWORD) { + logger.error('API username or password not configured.'); + return res.status(500).json({ error: 'Server configuration error.' }); + } + + try { + let cachedStaffs = await getStaffData(); + if (cachedStaffs && cachedStaffs.lastCheck) { + logger.info('Cache HIT for staffs.'); + cachedStaffs.cache = "HIT"; + return res.json(cachedStaffs); + } + + logger.info('Cache MISS for staffs. Fetching from source.'); + const activityJson = await fetchActivityData(FIXED_STAFF_ACTIVITY_ID as string, USERNAME, PASSWORD); + if (activityJson) { + const staffMap = await structStaffData(activityJson); + let staffObject: StaffData = Object.fromEntries(staffMap); + staffObject.lastCheck = new Date().toISOString(); + staffObject.cache = "MISS"; + await setStaffData(staffObject); + res.json(staffObject); + } else { + logger.error(`Could not retrieve base data for staffs (activity ID ${FIXED_STAFF_ACTIVITY_ID}).`); + res.status(404).json({ error: `Could not retrieve base data for staff details.`, cache: "MISS" }); + } + } catch (error) { + logger.error('Error in /v1/staffs endpoint:', error); + res.status(500).json({ error: 'An internal server error occurred while fetching staff data.', cache: "ERROR" }); + } +}); + +// Function to perform background initialization and periodic tasks +async function performBackgroundTasks(): Promise { + logger.info('Starting background initialization tasks...'); + try { + await initializeClubCache(); + await initializeOrUpdateStaffCache(true); + await cleanupOrphanedS3Images(); + + logger.info(`Setting up periodic club cache updates every ${CLUB_CHECK_INTERVAL_SECONDS} seconds.`); + setInterval(updateStaleClubs, CLUB_CHECK_INTERVAL_SECONDS * 1000); + + logger.info(`Setting up periodic staff cache updates every ${STAFF_CHECK_INTERVAL_SECONDS} seconds.`); + setInterval(() => initializeOrUpdateStaffCache(false), STAFF_CHECK_INTERVAL_SECONDS * 1000); + + logger.info('Background initialization and periodic task setup complete.'); + } catch (error) { + logger.error('Error during background initialization tasks:', error); + } +} + +// --- Start Server and Background Tasks --- +async function startServer(): Promise { + const redis = getRedisClient(); + if (!redis) { + logger.error('Redis client is not initialized. Server cannot start. Check REDIS_URL.'); + process.exit(1); + } + + try { + // Test Redis connection with a simple command + await redis.set('connection-test', 'ok'); + await redis.del('connection-test'); + logger.info('Redis connection confirmed.'); + + app.listen(PORT, () => { + logger.info(`Server is running on http://localhost:${PORT}`); + logger.info(`Allowed CORS origins: ${allowedOriginsEnv === '*' ? 'All (*)' : allowedOriginsEnv}`); + if (!USERNAME || !PASSWORD) { + logger.warn('Warning: API_USERNAME or API_PASSWORD is not set.'); + } + }); + + performBackgroundTasks().catch(error => { + logger.error('Unhandled error in performBackgroundTasks:', error); + }); + + } catch (err) { + logger.error('Failed to connect to Redis or critical error during server startup. Server not started.', err); + process.exit(1); + } +} + +// Bun's process event handlers +process.on('SIGINT', async () => { + logger.info('Server shutting down (SIGINT)...'); + await closeRedisConnection(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + logger.info('Server shutting down (SIGTERM)...'); + await closeRedisConnection(); + process.exit(0); +}); + +// Start the server if not in test mode +if (process.env.NODE_ENV !== 'test') { + startServer(); +} + +export { app }; \ No newline at end of file diff --git a/models/activity.ts b/models/activity.ts new file mode 100644 index 0000000..059ad97 --- /dev/null +++ b/models/activity.ts @@ -0,0 +1,26 @@ +// src/models/activity.ts +export interface ActivityData { + // Include all common properties + id?: string | null; + name?: string | null; + description?: string | null; + photo?: string | null | undefined; + academicYear?: string | null; + category?: string | null; + isPreSignup?: boolean | null; + isStudentLed?: boolean | null; + materials?: any[]; + poorWeatherPlan?: string | null; + requirements?: any[]; + schedule?: string | null; + semesterCost?: string | null; + staff?: string[]; + staffForReports?: string[]; + studentLeaders?: string[]; + // Cache-related properties + lastCheck?: string; + error?: string; + source?: string; + cache?: string; + [key: string]: any; // Allow additional properties +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..222107f --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "dsas-cca-backend-bun", + "private": true, + "scripts": { + "start": "bun run index.ts", + "dev": "bun run --watch index.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "axios": "^1.9.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "p-limit": "^6.2.0", + "pangu": "^4.0.7", + "uuid": "^11.1.0" + } +} diff --git a/services/cache-manager.ts b/services/cache-manager.ts new file mode 100644 index 0000000..642fb76 --- /dev/null +++ b/services/cache-manager.ts @@ -0,0 +1,253 @@ +// services/cache-manager.ts +import { config } from 'dotenv'; +import pLimit from 'p-limit'; +import { fetchActivityData } from '../engage-api/get-activity'; +import { structActivityData } from '../engage-api/struct-activity'; +import { structStaffData } from '../engage-api/struct-staff'; +import { + getActivityData, + setActivityData, + getStaffData, + setStaffData, + getAllActivityKeys, + ACTIVITY_KEY_PREFIX +} from './redis-service'; +import { uploadImageFromBase64, listS3Objects, deleteS3Objects, constructS3Url } from './s3-service'; +import { extractBase64Image } from '../utils/image-processor'; +import { logger } from '../utils/logger'; + +import type { ActivityData } from '../models/activity'; + +config(); + +// Environment configuration +const USERNAME = process.env.API_USERNAME; +const PASSWORD = process.env.API_PASSWORD; +const MIN_ACTIVITY_ID_SCAN = parseInt(process.env.MIN_ACTIVITY_ID_SCAN || '0', 10); +const MAX_ACTIVITY_ID_SCAN = parseInt(process.env.MAX_ACTIVITY_ID_SCAN || '9999', 10); +const CONCURRENT_API_CALLS = parseInt(process.env.CONCURRENT_API_CALLS || '10', 10); +const CLUB_UPDATE_INTERVAL_MINS = parseInt(process.env.CLUB_UPDATE_INTERVAL_MINS || '60', 10); +const STAFF_UPDATE_INTERVAL_MINS = parseInt(process.env.STAFF_UPDATE_INTERVAL_MINS || '60', 10); +const FIXED_STAFF_ACTIVITY_ID = process.env.FIXED_STAFF_ACTIVITY_ID; +const S3_IMAGE_PREFIX = (process.env.S3_PUBLIC_URL_PREFIX || 'files').replace(/\/$/, ''); + +// Limit concurrent API calls +const limit = pLimit(CONCURRENT_API_CALLS); + +/** + * Process and cache a single activity + * @param activityId - The activity ID to process + * @returns The processed activity data + */ +async function processAndCacheActivity(activityId: string): Promise { + logger.debug(`Processing activity ID: ${activityId}`); + try { + if (!USERNAME || !PASSWORD) { + throw new Error('API username or password not configured'); + } + + const activityJson = await fetchActivityData(activityId, USERNAME, PASSWORD); + let structuredActivity: ActivityData; + + if (!activityJson) { + logger.info(`No data found for activity ID ${activityId} from engage API. Caching as empty.`); + structuredActivity = { + lastCheck: new Date().toISOString(), + source: 'api-fetch-empty' + }; + } else { + structuredActivity = await structActivityData(activityJson); + if (structuredActivity && structuredActivity.photo && + typeof structuredActivity.photo === 'string' && + structuredActivity.photo.startsWith('data:image')) { + + const imageInfo = extractBase64Image(structuredActivity.photo); + if (imageInfo) { + const s3Url = await uploadImageFromBase64( + imageInfo.base64Content, + imageInfo.format, + activityId + ); + + if (s3Url) { + structuredActivity.photo = s3Url; + } else { + logger.warn(`Failed S3 upload for activity ${activityId}. Photo may be base64 or null.`); + } + } + } + } + + structuredActivity.lastCheck = new Date().toISOString(); + await setActivityData(activityId, structuredActivity); + return structuredActivity; + } catch (error) { + logger.error(`Error processing activity ID ${activityId}:`, error); + const errorData: ActivityData = { + lastCheck: new Date().toISOString(), + error: "Failed to fetch or process" + }; + await setActivityData(activityId, errorData); + return errorData; + } +} + +/** + * Initialize the club cache by scanning through all activity IDs + */ +export async function initializeClubCache(): Promise { + logger.info(`Starting initial club cache population from ID ${MIN_ACTIVITY_ID_SCAN} to ${MAX_ACTIVITY_ID_SCAN}`); + const promises: Promise[] = []; + + for (let i = MIN_ACTIVITY_ID_SCAN; i <= MAX_ACTIVITY_ID_SCAN; i++) { + const activityId = String(i); + promises.push(limit(async () => { + const cachedData = await getActivityData(activityId); + if (!cachedData || + Object.keys(cachedData).length === 0 || + !cachedData.lastCheck || + cachedData.error) { + logger.debug(`Initializing cache for activity ID: ${activityId}`); + await processAndCacheActivity(activityId); + } + })); + } + + await Promise.all(promises); + logger.info('Initial club cache population finished.'); +} + +/** + * Update stale clubs in the cache + */ +export async function updateStaleClubs(): Promise { + logger.info('Starting stale club check...'); + const now = Date.now(); + const updateIntervalMs = CLUB_UPDATE_INTERVAL_MINS * 60 * 1000; + const promises: Promise[] = []; + const activityKeys = await getAllActivityKeys(); + + for (const key of activityKeys) { + const activityId = key.substring(ACTIVITY_KEY_PREFIX.length); + promises.push(limit(async () => { + const cachedData = await getActivityData(activityId); + + if (cachedData && cachedData.lastCheck) { + const lastCheckTime = new Date(cachedData.lastCheck).getTime(); + if ((now - lastCheckTime) > updateIntervalMs || cachedData.error) { + logger.info(`Activity ${activityId} is stale or had error. Updating...`); + await processAndCacheActivity(activityId); + } + } else if (!cachedData || Object.keys(cachedData).length === 0) { + logger.info(`Activity ${activityId} not in cache or is empty object. Attempting to fetch...`); + await processAndCacheActivity(activityId); + } + })); + } + + await cleanupOrphanedS3Images(); + await Promise.all(promises); + logger.info('Stale club check finished.'); +} + +/** + * Initialize or update the staff cache + * @param forceUpdate - Force an update regardless of staleness + */ +export async function initializeOrUpdateStaffCache(forceUpdate: boolean = false): Promise { + logger.info('Starting staff cache check/update...'); + try { + const cachedStaffData = await getStaffData(); + const now = Date.now(); + const updateIntervalMs = STAFF_UPDATE_INTERVAL_MINS * 60 * 1000; + let needsUpdate = forceUpdate; + + if (!cachedStaffData || !cachedStaffData.lastCheck) { + needsUpdate = true; + } else { + const lastCheckTime = new Date(cachedStaffData.lastCheck).getTime(); + if ((now - lastCheckTime) > updateIntervalMs) { + needsUpdate = true; + } + } + + if (needsUpdate && USERNAME && PASSWORD && FIXED_STAFF_ACTIVITY_ID) { + logger.info('Staff data needs update. Fetching...'); + const activityJson = await fetchActivityData(FIXED_STAFF_ACTIVITY_ID, USERNAME, PASSWORD); + + if (activityJson) { + const staffMap = await structStaffData(activityJson); + const staffObject = Object.fromEntries(staffMap); + staffObject.lastCheck = new Date().toISOString(); + await setStaffData(staffObject); + logger.info('Staff data updated and cached.'); + } else { + logger.warn(`Could not retrieve base data for staff (activity ID ${FIXED_STAFF_ACTIVITY_ID}).`); + if (cachedStaffData && cachedStaffData.lastCheck) { + cachedStaffData.lastCheck = new Date().toISOString(); + await setStaffData(cachedStaffData); + } + } + } else { + logger.info('Staff data is up-to-date.'); + } + } catch (error) { + logger.error('Error initializing or updating staff cache:', error); + } +} + +/** + * Clean up orphaned S3 images + */ +export async function cleanupOrphanedS3Images(): Promise { + logger.info('Starting S3 orphan image cleanup...'); + const s3ObjectListPrefix = S3_IMAGE_PREFIX ? `${S3_IMAGE_PREFIX}/` : ''; + + try { + const referencedS3Urls = new Set(); + const allActivityRedisKeys = await getAllActivityKeys(); + const S3_ENDPOINT = process.env.S3_ENDPOINT; + + for (const redisKey of allActivityRedisKeys) { + const activityId = redisKey.substring(ACTIVITY_KEY_PREFIX.length); + const activityData = await getActivityData(activityId); + + if (activityData && + typeof activityData.photo === 'string' && + activityData.photo.startsWith('http') && + S3_ENDPOINT && + activityData.photo.startsWith(S3_ENDPOINT)) { + referencedS3Urls.add(activityData.photo); + } + } + + logger.info(`Found ${referencedS3Urls.size} unique S3 URLs referenced in Redis.`); + + const s3ObjectKeys = await listS3Objects(s3ObjectListPrefix); + if (!s3ObjectKeys || s3ObjectKeys.length === 0) { + logger.info(`No images found in S3 under prefix "${s3ObjectListPrefix}". Nothing to clean up.`); + return; + } + + logger.debug(`Found ${s3ObjectKeys.length} objects in S3 under prefix "${s3ObjectListPrefix}".`); + + const orphanedObjectKeys: string[] = []; + for (const objectKey of s3ObjectKeys) { + const s3Url = constructS3Url(objectKey); + if (s3Url && !referencedS3Urls.has(s3Url)) { + orphanedObjectKeys.push(objectKey); + } + } + + if (orphanedObjectKeys.length > 0) { + logger.info(`Found ${orphanedObjectKeys.length} orphaned S3 objects to delete. Submitting deletion...`); + await deleteS3Objects(orphanedObjectKeys); + } else { + logger.info('No orphaned S3 images found after comparison.'); + } + + logger.info('S3 orphan image cleanup finished.'); + } catch (error) { + logger.error('Error during S3 orphan image cleanup:', error); + } +} diff --git a/services/redis-service.ts b/services/redis-service.ts new file mode 100644 index 0000000..25c0765 --- /dev/null +++ b/services/redis-service.ts @@ -0,0 +1,149 @@ +// services/redis-service.ts +import { RedisClient } from "bun"; +import { config } from 'dotenv'; +import { logger } from '../utils/logger'; + +config(); + +export const ACTIVITY_KEY_PREFIX = 'activity:'; // Exported for use in cache-manager +const STAFF_KEY = 'staffs:all'; + +// Always create a new client instance with .env config +const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; +let redisClient: RedisClient | null = null; + +try { + redisClient = new RedisClient(redisUrl); + logger.info('Redis client initialized. Connection will be established on first command.'); +} catch (error) { + logger.error('Failed to initialize Redis client:', error); +} + +/** + * Gets activity data from Redis. + * @param activityId - The activity ID to fetch + * @returns Parsed JSON object or null if not found/error + */ +export async function getActivityData(activityId: string): Promise { + if (!redisClient) { + logger.warn('Redis client not available, skipping getActivityData'); + return null; + } + try { + const data = await redisClient.get(`${ACTIVITY_KEY_PREFIX}${activityId}`); + return data ? JSON.parse(data) : null; + } catch (err) { + logger.error(`Error getting activity ${activityId} from Redis:`, err); + return null; + } +} + +/** + * Sets activity data in Redis. + * @param activityId - The activity ID to set + * @param data - The activity data object + */ +export async function setActivityData(activityId: string, data: any): Promise { + if (!redisClient) { + logger.warn('Redis client not available, skipping setActivityData'); + return; + } + try { + await redisClient.set(`${ACTIVITY_KEY_PREFIX}${activityId}`, JSON.stringify(data)); + } catch (err) { + logger.error(`Error setting activity ${activityId} in Redis:`, err); + } +} + +/** + * Gets staff data from Redis. + * @returns Parsed JSON object or null if not found/error + */ +export async function getStaffData(): Promise { + if (!redisClient) { + logger.warn('Redis client not available, skipping getStaffData'); + return null; + } + try { + const data = await redisClient.get(STAFF_KEY); + return data ? JSON.parse(data) : null; + } catch (err) { + logger.error('Error getting staff data from Redis:', err); + return null; + } +} + +/** + * Sets staff data in Redis. + * @param data - The staff data object + */ +export async function setStaffData(data: any): Promise { + if (!redisClient) { + logger.warn('Redis client not available, skipping setStaffData'); + return; + } + try { + await redisClient.set(STAFF_KEY, JSON.stringify(data)); + } catch (err) { + logger.error('Error setting staff data in Redis:', err); + } +} + +/** + * Gets all activity keys from Redis. + * This can be resource-intensive on large datasets. Use with caution. + * @returns An array of keys + */ +export async function getAllActivityKeys(): Promise { + if (!redisClient) { + logger.warn('Redis client not available, skipping getAllActivityKeys'); + return []; + } + try { + // Using raw SCAN command since Bun's RedisClient doesn't have a scan method + const keys: string[] = []; + let cursor = '0'; + + do { + // Use send method to execute raw Redis commands + const result = await redisClient.send('SCAN', [ + cursor, + 'MATCH', + `${ACTIVITY_KEY_PREFIX}*`, + 'COUNT', + '100' + ]); + + cursor = result[0]; + const foundKeys = result[1] || []; + + // Add the found keys to our array + keys.push(...foundKeys); + + } while (cursor !== '0'); + + logger.info(`Found ${keys.length} activity keys in Redis using SCAN.`); + return keys; + } catch (err) { + logger.error('Error getting all activity keys from Redis using SCAN:', err); + return []; // Return empty array on error + } +} + +/** + * Gets the Redis client instance. + * @returns The Redis client or null if not initialized + */ +export function getRedisClient(): RedisClient | null { + return redisClient; +} + +/** + * Closes the Redis connection. + */ +export async function closeRedisConnection(): Promise { + if (redisClient) { + redisClient.close(); + logger.info('Redis connection closed.'); + } +} diff --git a/services/s3-service.ts b/services/s3-service.ts new file mode 100644 index 0000000..e9e50d4 --- /dev/null +++ b/services/s3-service.ts @@ -0,0 +1,203 @@ +// services/s3-service.ts +import { S3Client } from "bun"; +import { v4 as uuidv4 } from 'uuid'; +import { config } from 'dotenv'; +import { logger } from '../utils/logger'; +import { decodeBase64Image } from '../utils/image-processor'; + +config(); + +// S3 configuration +const S3_ENDPOINT = process.env.S3_ENDPOINT; +const S3_REGION = process.env.S3_REGION; +const S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID; +const S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY; +const BUCKET_NAME = process.env.S3_BUCKET_NAME; +const PUBLIC_URL_FILE_PREFIX = (process.env.S3_PUBLIC_URL_PREFIX || 'files').replace(/\/$/, ''); + +// Initialize S3 client +let s3Client: S3Client | null = null; + +if (S3_ACCESS_KEY_ID && S3_SECRET_ACCESS_KEY && BUCKET_NAME) { + try { + s3Client = new S3Client({ + accessKeyId: S3_ACCESS_KEY_ID, + secretAccessKey: S3_SECRET_ACCESS_KEY, + bucket: BUCKET_NAME, + endpoint: S3_ENDPOINT, + region: S3_REGION + }); + logger.info('S3 client initialized successfully.'); + } catch (error) { + logger.error('Failed to initialize S3 client:', error); + } +} else { + logger.warn('S3 client configuration is incomplete. S3 operations will be disabled.'); +} + +/** + * Uploads an image from a base64 string to S3. + * @param base64Data - The base64 content (without the data URI prefix) + * @param originalFormat - The image format (e.g., 'png', 'jpeg') + * @param activityId - The activity ID, used for naming + * @returns The public URL of the uploaded image or null on error + */ +export async function uploadImageFromBase64( + base64Data: string, + originalFormat: string, + activityId: string +): Promise { + if (!s3Client) { + logger.warn('S3 client not configured. Cannot upload image.'); + return null; + } + if (!base64Data || !originalFormat || !activityId) { + logger.error('S3 Upload: Missing base64Data, originalFormat, or activityId'); + return null; + } + + try { + const imageBuffer = decodeBase64Image(base64Data); + const objectKey = `${PUBLIC_URL_FILE_PREFIX}/activity-${activityId}-${uuidv4()}.${originalFormat}`; + + // Using Bun's S3Client file API + const s3File = s3Client.file(objectKey); + + await s3File.write(imageBuffer, { + type: `image/${originalFormat}`, + acl: 'public-read' + }); + + const publicUrl = constructS3Url(objectKey); + logger.info(`Image uploaded to S3: ${publicUrl}`); + return publicUrl; + } catch (error) { + logger.error(`S3 Upload Error for activity ${activityId}:`, error); + return null; + } +} + +/** + * Lists all objects in the S3 bucket under a specific prefix. + * @param prefix - The prefix to filter objects by + * @returns A list of object keys + */ +export async function listS3Objects(prefix: string): Promise { + if (!s3Client) { + logger.warn('S3 client not configured. Cannot list objects.'); + return []; + } + + logger.debug(`Listing objects from S3 with prefix: "${prefix}"`); + + try { + const objectKeys: string[] = []; + let isTruncated = true; + let startAfter: string | undefined; + + while (isTruncated) { + // Use Bun's list method with pagination + const result = await s3Client.list({ + prefix, + startAfter, + maxKeys: 1000 + }); + + if (result.contents) { + // Add keys to our array, filtering out "directories" + result.contents.forEach(item => { + if (item.key && !item.key.endsWith('/')) { + objectKeys.push(item.key); + } + }); + + // Get the last key for pagination + if (result.contents?.length > 0) { + startAfter = result.contents[result.contents.length - 1]?.key; + } + } + + isTruncated = result.isTruncated || false; + + // Safety check to prevent infinite loops + if (result.contents?.length === 0) { + break; + } + } + + logger.info(`Listed ${objectKeys.length} object keys from S3 with prefix "${prefix}"`); + return objectKeys; + } catch (error) { + logger.error(`S3 ListObjects Error with prefix "${prefix}":`, error); + return []; + } +} + +/** + * Deletes multiple objects from S3. + * @param objectKeysArray - Array of object keys to delete + * @returns True if successful or partially successful, false on major error + */ +export async function deleteS3Objects(objectKeysArray: string[]): Promise { + if (!s3Client) { + logger.warn('S3 client not configured. Cannot delete objects.'); + return false; + } + if (!objectKeysArray || objectKeysArray.length === 0) { + logger.info('No objects to delete from S3.'); + return true; + } + + try { + // With Bun's S3Client, we need to delete objects one by one + // Process in batches of 100 for better performance + const BATCH_SIZE = 100; + let successCount = 0; + let errorCount = 0; + + for (let i = 0; i < objectKeysArray.length; i += BATCH_SIZE) { + const batch = objectKeysArray.slice(i, i + BATCH_SIZE); + + // Process batch in parallel + const results = await Promise.allSettled( + batch.map(key => s3Client!.delete(key)) + ); + + // Count successes and failures + for (const result of results) { + if (result.status === 'fulfilled') { + successCount++; + } else { + errorCount++; + logger.error(`Failed to delete object: ${result.reason}`); + } + } + } + + logger.info(`Deleted ${successCount} objects from S3. Failed: ${errorCount}`); + return errorCount === 0; // True if all succeeded + } catch (error) { + logger.error('S3 DeleteObjects Error:', error); + return false; + } +} + +/** + * Constructs the public S3 URL for an object key. + * @param objectKey - The key of the object in S3 + * @returns The full public URL + */ +export function constructS3Url(objectKey: string): string { + if (!S3_ENDPOINT || !BUCKET_NAME) { + return ''; + } + + // Ensure S3_ENDPOINT does not end with a slash + const s3Base = S3_ENDPOINT.replace(/\/$/, ''); + // Ensure BUCKET_NAME does not start or end with a slash + const bucket = BUCKET_NAME.replace(/^\//, '').replace(/\/$/, ''); + // Ensure objectKey does not start with a slash + const key = objectKey.replace(/^\//, ''); + + return `${s3Base}/${bucket}/${key}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9c62f74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/utils/image-processor.ts b/utils/image-processor.ts new file mode 100644 index 0000000..c0c7918 --- /dev/null +++ b/utils/image-processor.ts @@ -0,0 +1,74 @@ +// ./utils/image-processor.ts +import { logger } from './logger'; // Updated import path + +/** + * Interface for image extraction result + */ +interface ImageInfo { + base64Content: string; + format: string; +} + +/** + * Interface for image format markers + */ +interface ImageMarker { + prefix: string; + format: string; +} + +/** + * Extracts base64 content and format from a data URL string. + * E.g., "data:image/jpeg;base64,xxxxxxxxxxxxxxx" + * @param {string} dataUrl The full data URL string. + * @returns {ImageInfo|null} An object { base64Content: string, format: string } or null if not found. + */ +export function extractBase64Image(dataUrl: string): ImageInfo | null { + if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { + return null; + } + + const markers: ImageMarker[] = [ + { prefix: "data:image/png;base64,", format: "png" }, + { prefix: "data:image/jpeg;base64,", format: "jpeg" }, + { prefix: "data:image/jpg;base64,", format: "jpg" }, + { prefix: "data:image/gif;base64,", format: "gif" }, + { prefix: "data:image/svg+xml;base64,", format: "svg" }, // svg+xml -> svg + { prefix: "data:image/webp;base64,", format: "webp" } + ]; + + for (const marker of markers) { + if (dataUrl.startsWith(marker.prefix)) { + const base64Content = dataUrl.substring(marker.prefix.length); + logger.debug(`Found image of format: ${marker.format}`); + return { base64Content, format: marker.format }; + } + } + + logger.warn("No known base64 image marker found in the provided data URL:", dataUrl.substring(0, 50) + "..."); + return null; +} + +/** + * Decodes a base64 string to a Uint8Array (Bun compatible). + * Bun has optimized Buffer operations, which are compatible with Node's Buffer + * @param {string} base64String The base64 encoded string (without the data URI prefix). + * @returns {Uint8Array} The decoded binary data + */ +export function decodeBase64Image(base64String: string): Uint8Array { + // Bun uses Node.js Buffer API and has highly optimized Buffer operations + return Buffer.from(base64String, 'base64'); +} + +/** + * Utility to convert a data URL directly to a binary buffer. + * Helpful for working with file APIs in Bun. + * @param {string} dataUrl The complete data URL + * @returns {Uint8Array|null} The decoded image data or null if invalid + */ +export function dataUrlToBuffer(dataUrl: string): Uint8Array | null { + const imageInfo = extractBase64Image(dataUrl); + if (!imageInfo) return null; + + return decodeBase64Image(imageInfo.base64Content); +} \ No newline at end of file diff --git a/utils/logger.ts b/utils/logger.ts new file mode 100644 index 0000000..aae8484 --- /dev/null +++ b/utils/logger.ts @@ -0,0 +1,41 @@ +// ./utils/logger.ts +import { config } from 'dotenv'; +config(); // Ensure .env variables are loaded + +// Define log level type +type LogLevel = 'error' | 'warn' | 'info' | 'debug'; + +// Interface for the logger object +interface Logger { + error: (...args: any[]) => void; + warn: (...args: any[]) => void; + info: (...args: any[]) => void; + debug: (...args: any[]) => void; +} + +const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() as LogLevel; + +const levels: Record = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const currentLevel = levels[LOG_LEVEL] ?? levels.info; + +const log = (level: LogLevel, ...args: any[]): void => { + if (levels[level] <= currentLevel) { + const timestamp = new Date().toISOString(); + console[level](`[${timestamp}] [${level.toUpperCase()}]`, ...args); + } +}; + +export const logger: Logger = { + error: (...args: any[]) => log('error', ...args), + warn: (...args: any[]) => log('warn', ...args), + info: (...args: any[]) => log('info', ...args), + debug: (...args: any[]) => log('debug', ...args), +}; + +export default logger; \ No newline at end of file