diff --git a/Dockerfile b/Dockerfile index b32cdcf..2c646d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,9 @@ ENV DOCKER_ENV=true # 生成生产构建 RUN pnpm run build +# 使用 pnpm deploy 提取生产依赖到独立目录 +RUN pnpm deploy --filter=. --prod --legacy /tmp/prod-deps + # ---- 第 3 阶段:生成运行时镜像 ---- FROM node:24-alpine AS runner @@ -55,8 +58,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/server.js ./server.js COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static -# 安装 Socket.IO 相关依赖(standalone 模式不会自动包含) -RUN pnpm add socket.io@^4.8.1 socket.io-client@^4.8.1 --prod +# 从构建器中复制生产依赖(包含 Socket.IO) +COPY --from=builder --chown=nextjs:nodejs /tmp/prod-deps/node_modules ./node_modules # 切换到非特权用户 USER nextjs diff --git a/package.json b/package.json index 0d2d47c..c8d455e 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "next-pwa": "^5.6.0", "next-themes": "^0.4.6", "node-fetch": "^2.7.0", + "nodemailer": "^7.0.12", "nprogress": "^0.2.0", "opencc-js": "^1.0.5", "parse-torrent-name": "^0.5.4", @@ -79,6 +80,7 @@ "@types/he": "^1.2.3", "@types/node": "24.0.3", "@types/node-fetch": "^2.6.13", + "@types/nodemailer": "^7.0.5", "@types/react": "^18.3.18", "@types/react-dom": "^19.1.6", "@types/react-window": "^2.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 917ed6a..2ef9976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: node-fetch: specifier: ^2.7.0 version: 2.7.0 + nodemailer: + specifier: ^7.0.12 + version: 7.0.12 nprogress: specifier: ^0.2.0 version: 0.2.0 @@ -180,6 +183,9 @@ importers: '@types/node-fetch': specifier: ^2.6.13 version: 2.6.13 + '@types/nodemailer': + specifier: ^7.0.5 + version: 7.0.5 '@types/react': specifier: ^18.3.18 version: 18.3.27 @@ -265,6 +271,135 @@ packages: peerDependencies: ajv: '>=8' + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-sesv2@3.970.0': + resolution: {integrity: sha512-QLqB8yluIhgXCr/0fY2f0gI3nQl0TInI+4/SFt9i1gGuvIVp7Nc6mFxjFe+CilfIadMqwjH28KvunirmDpWGPQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sso@3.970.0': + resolution: {integrity: sha512-ArmgnOsSCXN5VyIvZb4kSP5hpqlRRHolrMtKQ/0N8Hw4MTb7/IeYHSZzVPNzzkuX6gn5Aj8txoUnDPM8O7pc9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.970.0': + resolution: {integrity: sha512-klpzObldOq8HXzDjDlY6K8rMhYZU6mXRz6P9F9N+tWnjoYFfeBMra8wYApydElTUYQKP1O7RLHwH1OKFfKcqIA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.970.0': + resolution: {integrity: sha512-rtVzXzEtAfZBfh+lq3DAvRar4c3jyptweOAJR2DweyXx71QSMY+O879hjpMwES7jl07a3O1zlnFIDo4KP/96kQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.970.0': + resolution: {integrity: sha512-CjDbWL7JxjLc9ZxQilMusWSw05yRvUJKRpz59IxDpWUnSMHC9JMMUUkOy5Izk8UAtzi6gupRWArp4NG4labt9Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.970.0': + resolution: {integrity: sha512-L5R1hN1FY/xCmH65DOYMXl8zqCFiAq0bAq8tJZU32mGjIl1GzGeOkeDa9c461d81o7gsQeYzXyqFD3vXEbJ+kQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.970.0': + resolution: {integrity: sha512-C+1dcLr+p2E+9hbHyvrQTZ46Kj4vC2RoP6N935GEukHQa637ZjXs8VlyHJ2xTvbvwwLZQNiu56Cx7o/OFOqw1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.970.0': + resolution: {integrity: sha512-nMM0eeVuiLtw1taLRQ+H/H5Qp11rva8ILrzAQXSvlbDeVmbc7d8EeW5Q2xnCJu+3U+2JNZ1uxqIL22pB2sLEMA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.970.0': + resolution: {integrity: sha512-0XeT8OaT9iMA62DFV9+m6mZfJhrD0WNKf4IvsIpj2Z7XbaYfz3CoDDvNoALf3rPY9NzyMHgDxOspmqdvXP00mw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.970.0': + resolution: {integrity: sha512-ROb+Aijw8nzkB14Nh2XRH861++SeTZykUzk427y8YtgTLxjAOjgDTchDUFW2Fx6GFWkSjqJ3sY7SZyb33IqyFw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.970.0': + resolution: {integrity: sha512-r7tnYJJg+B6QvnsRHSW5vDol+ks6n+5jBZdCFdGyK63hjcMRMqHx59zEH8O47UR1PFv5hS2Q3uGz6HXvVtP40Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.969.0': + resolution: {integrity: sha512-AWa4rVsAfBR4xqm7pybQ8sUNJYnjyP/bJjfAw34qPuh3M9XrfGbAHG0aiAfQGrBnmS28jlO6Kz69o+c6PRw1dw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.969.0': + resolution: {integrity: sha512-xwrxfip7Y2iTtCMJ+iifN1E1XMOuhxIHY9DreMCvgdl4r7+48x2S1bCYPWH3eNY85/7CapBWdJ8cerpEl12sQQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.969.0': + resolution: {integrity: sha512-2r3PuNquU3CcS1Am4vn/KHFwLi8QFjMdA/R+CRDXT4AFO/0qxevF/YStW3gAKntQIgWgQV8ZdEtKAoJvLI4UWg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.970.0': + resolution: {integrity: sha512-v/Y5F1lbFFY7vMeG5yYxuhnn0CAshz6KMxkz1pDyPxejNE9HtA0w8R6OTBh/bVdIm44QpjhbI7qeLdOE/PLzXQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.970.0': + resolution: {integrity: sha512-dnSJGGUGSFGEX2NzvjwSefH+hmZQ347AwbLhAsi0cdnISSge+pcGfOFrJt2XfBIypwFe27chQhlfuf/gWdzpZg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.970.0': + resolution: {integrity: sha512-RIl8s4DCa31MXtRFw23iU90OqEoWuwQxiZOZshzsPtjyrunhHFjyZJEqb+vuQcYd1o22SMaYa3lPJRp64OH35Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.969.0': + resolution: {integrity: sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.970.0': + resolution: {integrity: sha512-z3syXfuK/x/IsKf/AeYmgc2NT7fcJ+3fHaGO+fkghkV9WEba3fPyOwtTBX4KpFMNb2t50zDGZwbzW1/5ighcUQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.970.0': + resolution: {integrity: sha512-YO8KgJecxHIFMhfoP880q51VXFL9V1ELywK5yzVEqzyrwqoG93IUmnTygBUylQrfkbH+QqS0FxEdgwpP3fcwoQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.969.0': + resolution: {integrity: sha512-7IIzM5TdiXn+VtgPdVLjmE6uUBUtnga0f4RiSEI1WW10RPuNvZ9U+pL3SwDiRDAdoGrOF9tSLJOFZmfuwYuVYQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.968.0': + resolution: {integrity: sha512-gqqvYcitIIM2K4lrDX9de9YvOfXBcVdxfT/iLnvHJd4YHvSXlt+gs+AsL4FfPCxG4IG9A+FyulP9Sb1MEA75vw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.970.0': + resolution: {integrity: sha512-TZNZqFcMUtjvhZoZRtpEGQAdULYiy6rcGiXAbLU7e9LSpIYlRqpLa207oMNfgbzlL2PnHko+eVg8rajDiSOYCg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.2': + resolution: {integrity: sha512-qKgO7wAYsXzhwCHhdbaKFyxd83Fgs8/1Ka+jjSPrv2Ll7mB55Wbwlo0kkfMLh993/yEc8aoDIAc1Fz9h4Spi4Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.969.0': + resolution: {integrity: sha512-bpJGjuKmFr0rA6UKUCmN8D19HQFMLXMx5hKBXqBlPFdalMhxJSjcxzX9DbQh0Fn6bJtxCguFmRGOBdQqNOt49g==} + + '@aws-sdk/util-user-agent-node@3.970.0': + resolution: {integrity: sha512-TNQpwIVD6SxMwkD+QKnaujKVyXy5ljN3O3jrI7nCHJ3GlJu5xJrd8yuBnanYCcrn3e2zwdfOh4d4zJAZvvIvVw==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.969.0': + resolution: {integrity: sha512-BSe4Lx/qdRQQdX8cSSI7Et20vqBspzAjBy8ZmXVoyLkol3y4sXBXzn+BiLtR+oh60ExQn6o2DU4QjdOZbXaKIQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1238,28 +1373,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -1408,6 +1539,178 @@ packages: '@sinonjs/fake-timers@8.1.0': resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.20.6': + resolution: {integrity: sha512-BpAffW1mIyRZongoKBbh3RgHG+JDHJek/8hjA/9LnPunM+ejorO6axkxCgwxCe4K//g/JdPeR9vROHDYr/hfnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.7': + resolution: {integrity: sha512-SCmhUG1UwtnEhF5Sxd8qk7bJwkj1BpFzFlHkXqKCEmDPLrRjJyTGM0EhqT7XBtDaDJjCfjRJQodgZcKDR843qg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.23': + resolution: {integrity: sha512-lLEmkQj7I7oKfvZ1wsnToGJouLOtfkMXDKRA1Hi6F+mMp5O1N8GcVWmVeNgTtgZtd0OTXDTI2vpVQmeutydGew==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.8': + resolution: {integrity: sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.10.8': + resolution: {integrity: sha512-wcr3UEL26k7lLoyf9eVDZoD1nNY3Fa1gbNuOXvfxvVWLGkOVW+RYZgUUp/bXHryJfycIOQnBq9o1JAE00ax8HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.22': + resolution: {integrity: sha512-O2WXr6ZRqPnbyoepb7pKcLt1QL6uRfFzGYJ9sGb5hMJQi7v/4RjRmCQa9mNjA0YiXqsc5lBmLXqJPhjM1Vjv5A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.25': + resolution: {integrity: sha512-7uMhppVNRbgNIpyUBVRfjGHxygP85wpXalRvn9DvUlCx4qgy1AB/uxOPSiDx/jFyrwD3/BypQhx1JK7f3yxrAw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.10': + resolution: {integrity: sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -1660,6 +1963,9 @@ packages: '@types/node@24.0.3': resolution: {integrity: sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==} + '@types/nodemailer@7.0.5': + resolution: {integrity: sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA==} + '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1828,49 +2134,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2288,6 +2586,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -3105,6 +3406,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4502,6 +4807,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + nodemailer@7.0.12: + resolution: {integrity: sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==} + engines: {node: '>=6.0.0'} + normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -5367,6 +5676,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + stubborn-fs@2.0.0: resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} @@ -6072,6 +6384,404 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-locate-window': 3.965.2 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.969.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-sesv2@3.970.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.970.0 + '@aws-sdk/credential-provider-node': 3.970.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.970.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/signature-v4-multi-region': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.970.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.970.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.6 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.7 + '@smithy/middleware-retry': 4.4.23 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.22 + '@smithy/util-defaults-mode-node': 4.2.25 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.970.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.970.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.970.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.970.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.970.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.6 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.7 + '@smithy/middleware-retry': 4.4.23 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.22 + '@smithy/util-defaults-mode-node': 4.2.25 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.970.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@aws-sdk/xml-builder': 3.969.0 + '@smithy/core': 3.20.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.10 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/credential-provider-env': 3.970.0 + '@aws-sdk/credential-provider-http': 3.970.0 + '@aws-sdk/credential-provider-login': 3.970.0 + '@aws-sdk/credential-provider-process': 3.970.0 + '@aws-sdk/credential-provider-sso': 3.970.0 + '@aws-sdk/credential-provider-web-identity': 3.970.0 + '@aws-sdk/nested-clients': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/nested-clients': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.970.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.970.0 + '@aws-sdk/credential-provider-http': 3.970.0 + '@aws-sdk/credential-provider-ini': 3.970.0 + '@aws-sdk/credential-provider-process': 3.970.0 + '@aws-sdk/credential-provider-sso': 3.970.0 + '@aws-sdk/credential-provider-web-identity': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.970.0': + dependencies: + '@aws-sdk/client-sso': 3.970.0 + '@aws-sdk/core': 3.970.0 + '@aws-sdk/token-providers': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/nested-clients': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-host-header@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-arn-parser': 3.968.0 + '@smithy/core': 3.20.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.10 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.970.0 + '@smithy/core': 3.20.6 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.970.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.970.0 + '@aws-sdk/middleware-host-header': 3.969.0 + '@aws-sdk/middleware-logger': 3.969.0 + '@aws-sdk/middleware-recursion-detection': 3.969.0 + '@aws-sdk/middleware-user-agent': 3.970.0 + '@aws-sdk/region-config-resolver': 3.969.0 + '@aws-sdk/types': 3.969.0 + '@aws-sdk/util-endpoints': 3.970.0 + '@aws-sdk/util-user-agent-browser': 3.969.0 + '@aws-sdk/util-user-agent-node': 3.970.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.20.6 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.7 + '@smithy/middleware-retry': 4.4.23 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.22 + '@smithy/util-defaults-mode-node': 4.2.25 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.970.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.970.0': + dependencies: + '@aws-sdk/core': 3.970.0 + '@aws-sdk/nested-clients': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.969.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.968.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.970.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.2': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.969.0': + dependencies: + '@aws-sdk/types': 3.969.0 + '@smithy/types': 4.12.0 + bowser: 2.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.970.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.970.0 + '@aws-sdk/types': 3.969.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.969.0': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -7544,6 +8254,280 @@ snapshots: dependencies: '@sinonjs/commons': 1.8.6 + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.20.6': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.10 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.7': + dependencies: + '@smithy/core': 3.20.6 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.23': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.10.8': + dependencies: + '@smithy/core': 3.20.6 + '@smithy/middleware-endpoint': 4.4.7 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.10 + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.22': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.25': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.10.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.10': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -7841,6 +8825,13 @@ snapshots: dependencies: undici-types: 7.8.0 + '@types/nodemailer@7.0.5': + dependencies: + '@aws-sdk/client-sesv2': 3.970.0 + '@types/node': 24.0.3 + transitivePeerDependencies: + - aws-crt + '@types/normalize-package-data@2.4.4': {} '@types/nprogress@0.2.3': {} @@ -8503,6 +9494,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -9482,6 +10475,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -11478,6 +12475,8 @@ snapshots: node-releases@2.0.27: {} + nodemailer@7.0.12: {} + normalize-package-data@2.5.0: dependencies: hosted-git-info: 2.8.9 @@ -12392,6 +13391,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.1.2: {} + stubborn-fs@2.0.0: dependencies: stubborn-utils: 1.0.2 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index a420d43..1ebf856 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -33,6 +33,7 @@ import { ExternalLink, FileText, FolderOpen, + Mail, Palette, Settings, Tv, @@ -8852,6 +8853,386 @@ const XiaoyaConfigComponent = ({ ); }; +// 邮件配置组件 +const EmailConfigComponent = ({ + config, + refreshConfig, +}: { + config: AdminConfig | null; + refreshConfig: () => Promise; +}) => { + const { alertModal, showAlert, hideAlert } = useAlertModal(); + const { isLoading, withLoading } = useLoadingState(); + const [enabled, setEnabled] = useState(false); + const [provider, setProvider] = useState<'smtp' | 'resend'>('smtp'); + + // SMTP配置 + const [smtpHost, setSmtpHost] = useState(''); + const [smtpPort, setSmtpPort] = useState(587); + const [smtpSecure, setSmtpSecure] = useState(false); + const [smtpUser, setSmtpUser] = useState(''); + const [smtpPassword, setSmtpPassword] = useState(''); + const [smtpFrom, setSmtpFrom] = useState(''); + + // Resend配置 + const [resendApiKey, setResendApiKey] = useState(''); + const [resendFrom, setResendFrom] = useState(''); + + // 测试邮件 + const [testEmail, setTestEmail] = useState(''); + + useEffect(() => { + if (config?.EmailConfig) { + setEnabled(config.EmailConfig.enabled || false); + setProvider(config.EmailConfig.provider || 'smtp'); + + if (config.EmailConfig.smtp) { + setSmtpHost(config.EmailConfig.smtp.host || ''); + setSmtpPort(config.EmailConfig.smtp.port || 587); + setSmtpSecure(config.EmailConfig.smtp.secure || false); + setSmtpUser(config.EmailConfig.smtp.user || ''); + setSmtpPassword(config.EmailConfig.smtp.password || ''); + setSmtpFrom(config.EmailConfig.smtp.from || ''); + } + + if (config.EmailConfig.resend) { + setResendApiKey(config.EmailConfig.resend.apiKey || ''); + setResendFrom(config.EmailConfig.resend.from || ''); + } + } + }, [config]); + + const handleSave = async () => { + await withLoading('saveEmail', async () => { + try { + const emailConfig: AdminConfig['EmailConfig'] = { + enabled, + provider, + smtp: provider === 'smtp' ? { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + user: smtpUser, + password: smtpPassword, + from: smtpFrom, + } : undefined, + resend: provider === 'resend' ? { + apiKey: resendApiKey, + from: resendFrom, + } : undefined, + }; + + const response = await fetch('/api/admin/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'save', + config: emailConfig, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || '保存失败'); + } + + showSuccess('保存成功', showAlert); + await refreshConfig(); + } catch (error) { + showError(error instanceof Error ? error.message : '保存失败', showAlert); + throw error; + } + }); + }; + + const handleTest = async () => { + if (!testEmail) { + showError('请输入测试邮箱地址', showAlert); + return; + } + + await withLoading('testEmail', async () => { + try { + const emailConfig: AdminConfig['EmailConfig'] = { + enabled: true, + provider, + smtp: provider === 'smtp' ? { + host: smtpHost, + port: smtpPort, + secure: smtpSecure, + user: smtpUser, + password: smtpPassword, + from: smtpFrom, + } : undefined, + resend: provider === 'resend' ? { + apiKey: resendApiKey, + from: resendFrom, + } : undefined, + }; + + const response = await fetch('/api/admin/email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'test', + config: emailConfig, + testEmail, + }), + }); + + const data = await response.json(); + if (data.success) { + showSuccess('测试邮件发送成功,请检查收件箱', showAlert); + } else { + showError(data.error || '发送失败', showAlert); + } + } catch (error) { + showError(error instanceof Error ? error.message : '发送失败', showAlert); + throw error; + } + }); + }; + + return ( +
+
+

+ 关于邮件通知 +

+
+

• 当用户收藏的影片有更新时,自动发送邮件通知

+

• 支持 SMTP 和 Resend 两种发送方式

+

• 用户可在个人设置中配置邮箱和通知偏好

+
+
+ +
+ {/* 启用开关 */} +
+
+

+ 启用邮件通知 +

+

+ 开启后用户可以接收收藏更新的邮件通知 +

+
+ +
+ + {/* 发送方式选择 */} +
+ +
+ + +
+
+ + {/* SMTP配置 */} + {provider === 'smtp' && ( +
+

SMTP 配置

+ +
+
+ + setSmtpHost(e.target.value)} + placeholder='smtp.gmail.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +
+ +
+ + setSmtpPort(parseInt(e.target.value))} + placeholder='587' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +
+
+ +
+ setSmtpSecure(e.target.checked)} + className='mr-2' + /> + +
+ +
+ + setSmtpUser(e.target.value)} + placeholder='your-email@gmail.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +
+ +
+ + setSmtpPassword(e.target.value)} + placeholder='应用专用密码' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +
+ +
+ + setSmtpFrom(e.target.value)} + placeholder='noreply@yourdomain.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +
+
+ )} + + {/* Resend配置 */} + {provider === 'resend' && ( +
+

Resend 配置

+ +
+ + setResendApiKey(e.target.value)} + placeholder='re_xxxxx' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +

+ 在 Resend 控制台 获取 +

+
+ +
+ + setResendFrom(e.target.value)} + placeholder='noreply@yourdomain.com' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white' + /> +

+ 需要先在 Resend 中验证域名 +

+
+
+ )} + + {/* 测试邮件 */} +
+

+ 发送测试邮件 +

+
+ setTestEmail(e.target.value)} + placeholder='输入测试邮箱地址' + className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm' + /> + +
+
+ + {/* 保存按钮 */} +
+ +
+
+ + +
+ ); +}; + // 求片列表组件 const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); @@ -10322,6 +10703,7 @@ function AdminPageClient() { dataMigration: false, customAdFilter: false, themeConfig: false, + emailConfig: false, }); // 获取管理员配置 @@ -10712,6 +11094,18 @@ function AdminPageClient() { + {/* 邮件配置标签 */} + + } + isExpanded={expandedTabs.emailConfig} + onToggle={() => toggleTab('emailConfig')} + > + + + {/* 分类配置标签 */} 0) { + try { + const userEmail = storage.getUserEmail ? await storage.getUserEmail(user) : null; + const emailNotifications = storage.getEmailNotificationPreference + ? await storage.getEmailNotificationPreference(user) + : false; + + if (userEmail && emailNotifications) { + const config = await getConfig(); + const emailConfig = config?.EmailConfig; + + if (emailConfig?.enabled) { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; + const siteName = config?.SiteConfig?.SiteName || 'MoonTVPlus'; + + await EmailService.send(emailConfig, { + to: userEmail, + subject: `📺 收藏更新汇总 - ${userUpdates.length} 部影片有更新`, + html: getBatchFavoriteUpdateEmailTemplate( + user, + userUpdates, + siteUrl, + siteName + ), + }); + + console.log(`邮件汇总已发送至: ${userEmail} (${userUpdates.length} 个更新)`); + } + } + } catch (emailError) { + console.error(`发送邮件汇总失败 (${user}):`, emailError); + // 邮件发送失败不影响主流程 + } + } } catch (err) { console.error(`获取用户收藏失败 (${user}):`, err); } diff --git a/src/app/api/favorites/check-updates/route.ts b/src/app/api/favorites/check-updates/route.ts deleted file mode 100644 index 917650c..0000000 --- a/src/app/api/favorites/check-updates/route.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; - -import { getAuthInfoFromCookie } from '@/lib/auth'; -import { getStorage } from '@/lib/db'; -import { getAvailableApiSites } from '@/lib/config'; -import { getDetailFromApi } from '@/lib/downstream'; -import { Notification } from '@/lib/types'; - -export const runtime = 'nodejs'; - -export async function POST(request: NextRequest) { - const authInfo = getAuthInfoFromCookie(request); - if (!authInfo || !authInfo.username) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - try { - const storage = getStorage(); - const username = authInfo.username; - const now = Date.now(); - - console.log(`用户 ${username} 请求检查收藏更新`); - console.log(`当前时间: ${new Date(now).toLocaleString('zh-CN')}`); - console.log(`开始检查收藏更新...`); - - // 获取所有收藏 - const favorites = await storage.getAllFavorites(username); - const favoriteKeys = Object.keys(favorites); - - if (favoriteKeys.length === 0) { - return NextResponse.json({ - message: '没有收藏', - updates: [], - }); - } - - // 获取可用的 API 站点 - const apiSites = await getAvailableApiSites(username); - - // 检查每个收藏的更新 - const updates: Array<{ - source: string; - id: string; - title: string; - old_episodes: number; - new_episodes: number; - }> = []; - - // 限制并发请求数量,避免过载 - const BATCH_SIZE = 5; - for (let i = 0; i < favoriteKeys.length; i += BATCH_SIZE) { - const batch = favoriteKeys.slice(i, i + BATCH_SIZE); - - await Promise.all( - batch.map(async (key) => { - try { - const favorite = favorites[key]; - - // 跳过 live 类型的收藏 - if (favorite.origin === 'live') { - return; - } - - // 跳过已完结的收藏 - if (favorite.is_completed) { - console.log(`跳过已完结的收藏: ${favorite.title}`); - return; - } - - // 解析 source 和 id - const [source, id] = key.split('+'); - if (!source || !id) { - return; - } - - // 查找对应的 API 站点 - const apiSite = apiSites.find((site) => site.key === source); - if (!apiSite) { - return; - } - - // 获取最新详情 - const detail = await getDetailFromApi(apiSite, id); - - // 比较集数 - const oldEpisodes = favorite.total_episodes; - const newEpisodes = detail.episodes.length; - - console.log(`检查收藏: ${favorite.title} (${source}+${id})`); - console.log(` 旧集数: ${oldEpisodes}, 新集数: ${newEpisodes}`); - console.log(` 是否完结: ${favorite.is_completed}, 备注: ${favorite.vod_remarks}`); - - if (newEpisodes > oldEpisodes) { - updates.push({ - source, - id, - title: favorite.title, - old_episodes: oldEpisodes, - new_episodes: newEpisodes, - }); - - // 更新收藏的集数和完结状态 - await storage.setFavorite(username, key, { - ...favorite, - total_episodes: newEpisodes, - is_completed: detail.vod_remarks - ? ['全', '完结', '大结局', 'end', '完'].some((keyword) => - detail.vod_remarks!.toLowerCase().includes(keyword) - ) - : false, - vod_remarks: detail.vod_remarks, - }); - } - } catch (error) { - console.error(`检查收藏更新失败 (${key}):`, error); - // 继续处理其他收藏 - } - }) - ); - } - - console.log(`检查完成,发现 ${updates.length} 个更新`); - - // 如果有更新,创建通知 - if (updates.length > 0) { - for (const update of updates) { - const notification: Notification = { - id: `fav_update_${update.source}_${update.id}_${now}`, - type: 'favorite_update', - title: '收藏更新', - message: `《${update.title}》有新集数更新!从 ${update.old_episodes} 集更新到 ${update.new_episodes} 集`, - timestamp: now, - read: false, - metadata: { - source: update.source, - id: update.id, - title: update.title, - old_episodes: update.old_episodes, - new_episodes: update.new_episodes, - }, - }; - - await storage.addNotification(username, notification); - } - } - - return NextResponse.json({ - message: updates.length > 0 ? `发现 ${updates.length} 个更新` : '没有更新', - updates, - checked: favoriteKeys.length, - }); - } catch (error) { - console.error('检查收藏更新失败:', error); - return NextResponse.json( - { error: (error as Error).message }, - { status: 500 } - ); - } -} diff --git a/src/app/api/user/email-settings/route.ts b/src/app/api/user/email-settings/route.ts new file mode 100644 index 0000000..299b279 --- /dev/null +++ b/src/app/api/user/email-settings/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getStorage } from '@/lib/db'; + +export const runtime = 'nodejs'; + +/** + * GET - 获取用户邮箱设置 + */ +export async function GET(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + const username = authInfo.username; + + const email = storage.getUserEmail + ? await storage.getUserEmail(username) + : null; + + const emailNotifications = storage.getEmailNotificationPreference + ? await storage.getEmailNotificationPreference(username) + : false; + + return NextResponse.json({ + email: email || '', + emailNotifications, + }); + } catch (error) { + console.error('获取用户邮箱设置失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} + +/** + * POST - 保存用户邮箱设置 + */ +export async function POST(request: NextRequest) { + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const storage = getStorage(); + const username = authInfo.username; + const body = await request.json(); + const { email, emailNotifications } = body; + + // 验证邮箱格式 + if (email && typeof email === 'string') { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: '邮箱格式不正确' }, + { status: 400 } + ); + } + + if (storage.setUserEmail) { + await storage.setUserEmail(username, email); + } + } + + // 保存邮件通知偏好 + if (typeof emailNotifications === 'boolean') { + if (storage.setEmailNotificationPreference) { + await storage.setEmailNotificationPreference(username, emailNotifications); + } + } + + return NextResponse.json({ + success: true, + message: '邮箱设置保存成功', + }); + } catch (error) { + console.error('保存用户邮箱设置失败:', error); + return NextResponse.json( + { error: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx index b72c635..8553106 100644 --- a/src/components/UserMenu.tsx +++ b/src/components/UserMenu.tsx @@ -15,6 +15,7 @@ import { Home, KeyRound, LogOut, + Mail, MoveDown, MoveUp, Rss, @@ -55,6 +56,7 @@ export const UserMenu: React.FC = () => { const [isOfflineDownloadPanelOpen, setIsOfflineDownloadPanelOpen] = useState(false); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [isFavoritesPanelOpen, setIsFavoritesPanelOpen] = useState(false); + const [isEmailSettingsOpen, setIsEmailSettingsOpen] = useState(false); const [authInfo, setAuthInfo] = useState(null); const [storageType, setStorageType] = useState('localstorage'); const [mounted, setMounted] = useState(false); @@ -68,7 +70,7 @@ export const UserMenu: React.FC = () => { // Body 滚动锁定 - 使用 overflow 方式避免布局问题 useEffect(() => { - if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen) { + if (isSettingsOpen || isChangePasswordOpen || isSubscribeOpen || isOfflineDownloadPanelOpen || isEmailSettingsOpen) { const body = document.body; const html = document.documentElement; @@ -87,7 +89,7 @@ export const UserMenu: React.FC = () => { html.style.overflow = originalHtmlOverflow; }; } - }, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen]); + }, [isSettingsOpen, isChangePasswordOpen, isSubscribeOpen, isOfflineDownloadPanelOpen, isEmailSettingsOpen]); // 设置相关状态 const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true); @@ -108,6 +110,10 @@ export const UserMenu: React.FC = () => { const [nextEpisodeDanmakuPreload, setNextEpisodeDanmakuPreload] = useState(true); const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false); + // 邮件通知设置 + const [userEmail, setUserEmail] = useState(''); + const [emailNotifications, setEmailNotifications] = useState(false); + // 折叠面板状态 const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true); const [isUsageSectionOpen, setIsUsageSectionOpen] = useState(false); @@ -390,9 +396,67 @@ export const UserMenu: React.FC = () => { if (savedSearchTraditionalToSimplified !== null) { setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true'); } + + // 加载邮件通知设置 + loadEmailSettings(); } }, []); + // 加载邮件通知设置 + const loadEmailSettings = async () => { + try { + const response = await fetch('/api/user/email-settings'); + if (response.ok) { + const data = await response.json(); + setUserEmail(data.email || ''); + setEmailNotifications(data.emailNotifications || false); + } + } catch (error) { + console.error('加载邮件设置失败:', error); + } + }; + + // 保存邮件通知设置 + const handleSaveEmailSettings = async () => { + try { + const response = await fetch('/api/user/email-settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: userEmail, + emailNotifications, + }), + }); + + const messageEl = document.getElementById('email-settings-message'); + if (response.ok) { + if (messageEl) { + messageEl.textContent = '保存成功!'; + messageEl.className = 'text-xs text-center text-green-600 dark:text-green-400'; + messageEl.classList.remove('hidden'); + setTimeout(() => { + messageEl.classList.add('hidden'); + }, 3000); + } + } else { + const data = await response.json(); + if (messageEl) { + messageEl.textContent = data.error || '保存失败'; + messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400'; + messageEl.classList.remove('hidden'); + } + } + } catch (error) { + console.error('保存邮件设置失败:', error); + const messageEl = document.getElementById('email-settings-message'); + if (messageEl) { + messageEl.textContent = '保存失败,请重试'; + messageEl.className = 'text-xs text-center text-red-600 dark:text-red-400'; + messageEl.classList.remove('hidden'); + } + } + }; + // 点击外部区域关闭下拉框 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -847,9 +911,23 @@ export const UserMenu: React.FC = () => {
- - 当前用户 - +
+ + 当前用户 + + {/* 邮件设置图标按钮 */} + +
{ ); + // 邮件设置面板内容 + const emailSettingsPanel = ( + <> + {/* 背景遮罩 */} +
setIsEmailSettingsOpen(false)} + onTouchMove={(e) => { + e.preventDefault(); + }} + onWheel={(e) => { + e.preventDefault(); + }} + style={{ + touchAction: 'none', + }} + /> + + {/* 邮件设置面板 */} +
+
{ + e.stopPropagation(); + }} + style={{ + touchAction: 'auto', + }} + > + {/* 标题栏 */} +
+

+ 邮件通知设置 +

+ +
+ + {/* 表单 */} +
+
+ + setUserEmail(e.target.value)} + placeholder='输入您的邮箱地址' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500' + /> +
+ +
+
+

+ 接收收藏更新通知 +

+

+ 当收藏的影片有更新时发送邮件通知 +

+
+ +
+ + + +

+
+ + {/* 提示信息 */} +
+

+ 💡 提示:需要管理员先在管理面板中配置邮件服务 +

+
+
+
+ + ); + return ( <>
@@ -2079,6 +2262,11 @@ export const UserMenu: React.FC = () => { />, document.body )} + + {/* 使用 Portal 将邮件设置面板渲染到 document.body */} + {isEmailSettingsOpen && + mounted && + createPortal(emailSettingsPanel, document.body)} ); }; diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 8af8fb1..1feb7bb 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -206,6 +206,24 @@ export interface AdminConfig { Password?: string; // 密码认证(备选) DisableVideoPreview?: boolean; // 禁用预览视频,直接返回直连链接 }; + EmailConfig?: { + enabled: boolean; // 是否启用邮件通知 + provider: 'smtp' | 'resend'; // 邮件发送方式 + // SMTP配置 + smtp?: { + host: string; // SMTP服务器地址 + port: number; // SMTP端口(25/465/587) + secure: boolean; // 是否使用SSL/TLS + user: string; // SMTP用户名 + password: string; // SMTP密码 + from: string; // 发件人邮箱 + }; + // Resend配置 + resend?: { + apiKey: string; // Resend API Key + from: string; // 发件人邮箱 + }; + }; } export interface AdminConfigResult { diff --git a/src/lib/email.service.ts b/src/lib/email.service.ts new file mode 100644 index 0000000..ec1919e --- /dev/null +++ b/src/lib/email.service.ts @@ -0,0 +1,182 @@ +import nodemailer from 'nodemailer'; +import type { AdminConfig } from './admin.types'; + +export interface EmailOptions { + to: string; + subject: string; + html: string; +} + +export class EmailService { + /** + * 通过SMTP发送邮件 + */ + static async sendViaSMTP( + config: NonNullable['smtp'], + options: EmailOptions + ): Promise { + if (!config) { + throw new Error('SMTP配置不存在'); + } + + const transporter = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: { + user: config.user, + pass: config.password, + }, + }); + + await transporter.sendMail({ + from: config.from, + to: options.to, + subject: options.subject, + html: options.html, + }); + } + + /** + * 通过Resend API发送邮件 + */ + static async sendViaResend( + config: NonNullable['resend'], + options: EmailOptions + ): Promise { + if (!config) { + throw new Error('Resend配置不存在'); + } + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: config.from, + to: options.to, + subject: options.subject, + html: options.html, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Resend API错误: ${response.statusText} - ${errorText}`); + } + } + + /** + * 统一发送接口 + */ + static async send( + emailConfig: AdminConfig['EmailConfig'], + options: EmailOptions + ): Promise { + if (!emailConfig || !emailConfig.enabled) { + console.log('邮件通知未启用,跳过发送'); + return; + } + + try { + if (emailConfig.provider === 'smtp' && emailConfig.smtp) { + await this.sendViaSMTP(emailConfig.smtp, options); + console.log(`邮件已通过SMTP发送至: ${options.to}`); + } else if (emailConfig.provider === 'resend' && emailConfig.resend) { + await this.sendViaResend(emailConfig.resend, options); + console.log(`邮件已通过Resend发送至: ${options.to}`); + } else { + throw new Error('邮件配置不完整'); + } + } catch (error) { + console.error('邮件发送失败:', error); + throw error; + } + } + + /** + * 发送测试邮件 + */ + static async sendTestEmail( + emailConfig: AdminConfig['EmailConfig'], + toEmail: string, + siteName?: string + ): Promise { + const displayName = siteName || 'MoonTVPlus'; + await this.send(emailConfig, { + to: toEmail, + subject: `测试邮件 - ${displayName}`, + html: ` + + + + + + + + +
+
+

📧 测试邮件

+
+
+

这是一封来自 ${displayName} 的测试邮件。

+

如果您收到这封邮件,说明邮件配置正确!

+

发送时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}

+
+ +
+ + + `, + }); + } +} diff --git a/src/lib/email.templates.ts b/src/lib/email.templates.ts new file mode 100644 index 0000000..205a596 --- /dev/null +++ b/src/lib/email.templates.ts @@ -0,0 +1,265 @@ +/** + * 邮件模板 + */ + +export interface FavoriteUpdate { + title: string; + oldEpisodes: number; + newEpisodes: number; + url: string; + cover?: string; +} + +/** + * 收藏更新邮件模板 + */ +export function getFavoriteUpdateEmailTemplate( + userName: string, + updates: FavoriteUpdate[], + siteUrl: string, + siteName?: string +): string { + const updatesList = updates + .map( + (u) => ` +
+ ${ + u.cover + ? `${u.title}` + : '' + } +
${u.title}
+
+ 更新:第 ${u.oldEpisodes} 集 → 第 ${u.newEpisodes} 集 +
+ 立即观看 +
+ ` + ) + .join(''); + + return ` + + + + + + + + +
+
+

📺 收藏更新通知

+
+
+
+ Hi ${userName}, +
+

您收藏的以下影片有更新:

+ ${updatesList} +

快去观看吧!

+
+ +
+ + + `; +} + +/** + * 单个收藏更新邮件模板(简化版) + */ +export function getSingleFavoriteUpdateEmailTemplate( + userName: string, + title: string, + oldEpisodes: number, + newEpisodes: number, + url: string, + cover?: string, + siteName?: string +): string { + return getFavoriteUpdateEmailTemplate( + userName, + [{ title, oldEpisodes, newEpisodes, url, cover }], + url.split('/play')[0] || 'http://localhost:3000', + siteName + ); +} + +/** + * 批量收藏更新邮件模板(每日汇总) + */ +export function getBatchFavoriteUpdateEmailTemplate( + userName: string, + updates: FavoriteUpdate[], + siteUrl: string, + siteName?: string +): string { + const totalUpdates = updates.length; + const totalNewEpisodes = updates.reduce( + (sum, u) => sum + (u.newEpisodes - u.oldEpisodes), + 0 + ); + + const updatesList = updates + .map( + (u) => ` +
+
+ ${ + u.cover + ? `${u.title}` + : '' + } +
+
${u.title}
+
+ 第 ${u.oldEpisodes} 集 → 第 ${u.newEpisodes} 集 + (+${u.newEpisodes - u.oldEpisodes}) +
+ 立即观看 +
+
+
+ ` + ) + .join(''); + + return ` + + + + + + + + +
+
+

📺 收藏更新汇总

+
+ ${totalUpdates} 部影片更新 · 共 ${totalNewEpisodes} 集新内容 +
+
+
+
+ Hi ${userName}, +
+

您收藏的影片有以下更新:

+ ${updatesList} +

快去观看吧!

+
+ +
+ + + `; +} diff --git a/src/lib/redis-base.db.ts b/src/lib/redis-base.db.ts index ec1ecbb..23610cc 100644 --- a/src/lib/redis-base.db.ts +++ b/src/lib/redis-base.db.ts @@ -4,6 +4,7 @@ import { createClient, RedisClientType } from 'redis'; import { AdminConfig } from './admin.types'; import { Favorite, IStorage, PlayRecord, SkipConfig } from './types'; +import { userInfoCache } from './user-cache'; // 搜索历史最大条数 const SEARCH_HISTORY_LIMIT = 20; @@ -660,6 +661,8 @@ export abstract class BaseRedisStorage implements IStorage { favorite_migrated?: boolean; skip_migrated?: boolean; last_movie_request_time?: number; + email?: string; + emailNotifications?: boolean; } | null> { const userInfo = await this.withRetry(() => this.client.hGetAll(this.userInfoKey(userName)) @@ -680,6 +683,8 @@ export abstract class BaseRedisStorage implements IStorage { favorite_migrated: userInfo.favorite_migrated === 'true', skip_migrated: userInfo.skip_migrated === 'true', last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined, + email: userInfo.email, + emailNotifications: userInfo.emailNotifications === 'true', }; } @@ -1290,4 +1295,31 @@ export abstract class BaseRedisStorage implements IStorage { async removeUserMovieRequest(userName: string, requestId: string): Promise { await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId)); } + + // ---------- 用户邮箱相关 ---------- + async getUserEmail(userName: string): Promise { + const userInfo = await this.getUserInfoV2(userName); + return userInfo?.email || null; + } + + async setUserEmail(userName: string, email: string): Promise { + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'email', email) + ); + // 清除缓存 + userInfoCache?.delete(userName); + } + + async getEmailNotificationPreference(userName: string): Promise { + const userInfo = await this.getUserInfoV2(userName); + return userInfo?.emailNotifications || false; + } + + async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { + await this.withRetry(() => + this.client.hSet(this.userInfoKey(userName), 'emailNotifications', enabled.toString()) + ); + // 清除缓存 + userInfoCache?.delete(userName); + } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 45d5215..2912a9c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -140,7 +140,15 @@ export interface IStorage { favorite_migrated?: boolean; skip_migrated?: boolean; last_movie_request_time?: number; + email?: string; // 用户邮箱 + emailNotifications?: boolean; // 是否接收邮件通知 } | null>; + + // 用户邮箱相关 + getUserEmail?(userName: string): Promise; + setUserEmail?(userName: string, email: string): Promise; + getEmailNotificationPreference?(userName: string): Promise; + setEmailNotificationPreference?(userName: string, enabled: boolean): Promise; } // 搜索结果数据结构 diff --git a/src/lib/upstash.db.ts b/src/lib/upstash.db.ts index 4c0345f..3f4f2e4 100644 --- a/src/lib/upstash.db.ts +++ b/src/lib/upstash.db.ts @@ -590,6 +590,8 @@ export class UpstashRedisStorage implements IStorage { favorite_migrated?: boolean; skip_migrated?: boolean; last_movie_request_time?: number; + email?: string; + emailNotifications?: boolean; } | null> { // 先从缓存获取 const cached = userInfoCache?.get(userName); @@ -688,6 +690,8 @@ export class UpstashRedisStorage implements IStorage { ? userInfo.last_movie_request_time : parseInt(userInfo.last_movie_request_time as string, 10)) : undefined, + email: userInfo.email as string | undefined, + emailNotifications: userInfo.emailNotifications === 'true' || userInfo.emailNotifications === true, }; // 存入缓存 @@ -1335,6 +1339,33 @@ export class UpstashRedisStorage implements IStorage { async removeUserMovieRequest(userName: string, requestId: string): Promise { await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId)); } + + // ---------- 用户邮箱相关 ---------- + async getUserEmail(userName: string): Promise { + const userInfo = await this.getUserInfoV2(userName); + return userInfo?.email || null; + } + + async setUserEmail(userName: string, email: string): Promise { + await withRetry(() => + this._client.hset(this.userInfoKey(userName), { email }) + ); + // 清除缓存 + userInfoCache?.delete(userName); + } + + async getEmailNotificationPreference(userName: string): Promise { + const userInfo = await this.getUserInfoV2(userName); + return userInfo?.emailNotifications || false; + } + + async setEmailNotificationPreference(userName: string, enabled: boolean): Promise { + await withRetry(() => + this._client.hset(this.userInfoKey(userName), { emailNotifications: enabled.toString() }) + ); + // 清除缓存 + userInfoCache?.delete(userName); + } } // 单例 Upstash Redis 客户端