@@ -4,3 +4,11 @@ TORBOX_API_KEY=
|
|||||||
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
|
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
|
||||||
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
|
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
|
||||||
ACCESS_TOKEN=
|
ACCESS_TOKEN=
|
||||||
|
# 日志级别:fatal / error / warn / info / debug / trace / silent
|
||||||
|
# 不配置时开发环境为 debug,生产环境为 info
|
||||||
|
LOG_LEVEL=
|
||||||
|
# 日志文件目录:配置后日志同时按天写入该目录(torbox-admin-YYYY-MM-DD.log),不配置则只输出到控制台
|
||||||
|
# Docker 部署时已在 workflow 中设为 /app/logs 并挂载到服务器的 /app/torbox-admin/logs
|
||||||
|
LOG_DIR=
|
||||||
|
# 日志文件保留天数,默认 14
|
||||||
|
LOG_RETAIN_DAYS=
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
name: torbox-admin
|
||||||
|
run-name: "${{ gitea.event.head_commit.message }}"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-torbox-admin
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: torbox-admin
|
||||||
|
# 与 central-platform-frontend(22786)同机部署,端口不能相同
|
||||||
|
HOST_PORT: 22787
|
||||||
|
# 服务器上的部署目录:.env(密钥与配置)、logs/(日志文件)都在这里,不进镜像也不进仓库
|
||||||
|
DEPLOY_DIR: /app/torbox-admin
|
||||||
|
DOCKER_BUILDKIT: "0"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on:
|
||||||
|
- vps_jp
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Prepare environment
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
if ! command -v docker > /dev/null 2>&1; then
|
||||||
|
apk add docker-cli
|
||||||
|
fi
|
||||||
|
if ! command -v git > /dev/null 2>&1; then
|
||||||
|
apk add git
|
||||||
|
fi
|
||||||
|
if ! command -v node > /dev/null 2>&1; then
|
||||||
|
apk add nodejs
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker --version
|
||||||
|
echo "Environment preparation took $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: Prepare host directory
|
||||||
|
# runner 本身可能是容器,看不到宿主机的 /app;借一个临时容器操作宿主机目录
|
||||||
|
# 缺少任一密钥时应用会进入演示模式或拒绝启动,部署前直接拦截(只检查是否存在,不输出内容)
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-e KEYS="ACCESS_TOKEN TORBOX_API_KEY" \
|
||||||
|
-e DEPLOY_DIR \
|
||||||
|
-v $DEPLOY_DIR:/data \
|
||||||
|
alpine:3 sh -c '
|
||||||
|
if [ ! -f /data/.env ]; then
|
||||||
|
echo "Missing $DEPLOY_DIR/.env on the server"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for key in $KEYS; do
|
||||||
|
if ! grep -Eq "^${key}=.+" /data/.env; then
|
||||||
|
echo "$key is not set in $DEPLOY_DIR/.env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
# 容器内以 node 用户(uid 1000)运行,需要能读 .env、写日志目录
|
||||||
|
mkdir -p /data/logs
|
||||||
|
chown 1000:1000 /data/logs /data/.env
|
||||||
|
chmod 600 /data/.env
|
||||||
|
'
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build application image
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
SHORT_SHA=$(echo "${{ gitea.sha }}" | cut -c1-8)
|
||||||
|
docker build \
|
||||||
|
--label "git.sha=${{ gitea.sha }}" \
|
||||||
|
-t $IMAGE_NAME:latest \
|
||||||
|
-t $IMAGE_NAME:$SHORT_SHA .
|
||||||
|
docker images $IMAGE_NAME:latest --format "Image size: {{.Size}}"
|
||||||
|
echo "Application image step took $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: Deploy container
|
||||||
|
run: |
|
||||||
|
STEP_START=$(date +%s)
|
||||||
|
|
||||||
|
if docker ps -a --filter "name=^${IMAGE_NAME}$" -q | grep -q .; then
|
||||||
|
docker stop --timeout 15 $IMAGE_NAME || true
|
||||||
|
docker rm $IMAGE_NAME || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 配置:.env 只读挂载,由应用启动时加载;日志:写入宿主机目录,容器重建不丢
|
||||||
|
# 控制台输出同时保留(docker logs),限制大小避免无限增长
|
||||||
|
docker run -d \
|
||||||
|
--name $IMAGE_NAME \
|
||||||
|
--network host \
|
||||||
|
-e PORT=$HOST_PORT \
|
||||||
|
-e HOSTNAME=127.0.0.1 \
|
||||||
|
-e TZ=Asia/Shanghai \
|
||||||
|
-e LOG_DIR=/app/logs \
|
||||||
|
-v $DEPLOY_DIR/.env:/app/.env:ro \
|
||||||
|
-v $DEPLOY_DIR/logs:/app/logs \
|
||||||
|
--log-opt max-size=10m \
|
||||||
|
--log-opt max-file=3 \
|
||||||
|
--restart unless-stopped \
|
||||||
|
$IMAGE_NAME:latest
|
||||||
|
|
||||||
|
MAX_WAIT=60
|
||||||
|
WAITED=0
|
||||||
|
HEALTH="unknown"
|
||||||
|
while [ $WAITED -lt $MAX_WAIT ]; do
|
||||||
|
RUNNING_ID=$(docker ps --filter "name=^${IMAGE_NAME}$" --filter "status=running" -q)
|
||||||
|
HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $IMAGE_NAME 2>/dev/null || echo "unknown")
|
||||||
|
if [ -n "$RUNNING_ID" ] && [ "$HEALTH" = "healthy" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
WAITED=$((WAITED + 2))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$HEALTH" = "healthy" ]; then
|
||||||
|
docker ps --filter "name=^${IMAGE_NAME}$" --format "Status: {{.Status}}"
|
||||||
|
echo "Startup wait: ${WAITED}s"
|
||||||
|
else
|
||||||
|
echo "Container not healthy after ${MAX_WAIT}s (health=$HEALTH)"
|
||||||
|
docker inspect $IMAGE_NAME --format 'ExitCode={{.State.ExitCode}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' 2>/dev/null || true
|
||||||
|
docker logs --tail 30 $IMAGE_NAME || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deployment step took $(( $(date +%s) - STEP_START ))s"
|
||||||
|
|
||||||
|
- name: Clean up old images
|
||||||
|
run: docker image prune -f
|
||||||
|
|
||||||
|
- name: Show startup logs
|
||||||
|
if: always()
|
||||||
|
run: docker logs --tail 15 $IMAGE_NAME
|
||||||
+9
-6
@@ -7,8 +7,8 @@ RUN npm install -g pnpm@10
|
|||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# NEXT_PUBLIC_* 会在构建时内联进浏览器代码,必须通过构建参数传入
|
# NEXT_PUBLIC_* 会在构建时内联进浏览器代码;默认同源 /api,不能留空(空串不会触发代码里的默认值)
|
||||||
ARG NEXT_PUBLIC_API_BASE_URL
|
ARG NEXT_PUBLIC_API_BASE_URL=/api
|
||||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
@@ -20,7 +20,8 @@ FROM node:22-alpine
|
|||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
HOSTNAME=127.0.0.1 \
|
HOSTNAME=127.0.0.1 \
|
||||||
PORT=22786
|
PORT=22787 \
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -30,9 +31,11 @@ COPY --from=builder --chown=node:node /app/public ./public
|
|||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
EXPOSE 22786
|
EXPOSE 22787
|
||||||
|
|
||||||
|
# 端口可由 -e PORT 覆盖,检查必须跟随实际端口(host 网络下写死会探测到别的服务)
|
||||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD wget -q --spider http://127.0.0.1:22786/login || exit 1
|
CMD wget -q --spider http://127.0.0.1:${PORT}/login || exit 1
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
# 配置由部署时挂载的 /app/.env 提供(不存在则忽略);已通过 -e 传入的变量优先,不会被覆盖
|
||||||
|
CMD ["node", "--env-file-if-exists=/app/.env", "server.js"]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"lucide-react": "^1.47.0",
|
"lucide-react": "^1.47.0",
|
||||||
"motion": "^14.0.0",
|
"motion": "^14.0.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
|
"pino": "^10.4.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|||||||
Generated
+92
@@ -44,6 +44,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.3.5
|
specifier: 16.3.5
|
||||||
version: 16.3.5(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 16.3.5(@babel/core@7.29.7)(@types/node@20.19.43)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
pino:
|
||||||
|
specifier: ^10.4.0
|
||||||
|
version: 10.4.0
|
||||||
react:
|
react:
|
||||||
specifier: 19.2.8
|
specifier: 19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
@@ -643,6 +646,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0':
|
||||||
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1141,6 +1147,10 @@ packages:
|
|||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0:
|
||||||
|
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
atomically@1.7.0:
|
atomically@1.7.0:
|
||||||
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==}
|
||||||
engines: {node: '>=10.12.0'}
|
engines: {node: '>=10.12.0'}
|
||||||
@@ -2550,6 +2560,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2:
|
||||||
|
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2672,6 +2686,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==}
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0:
|
||||||
|
resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
|
||||||
|
|
||||||
|
pino@10.4.0:
|
||||||
|
resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
pkce-challenge@5.0.1:
|
pkce-challenge@5.0.1:
|
||||||
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
|
||||||
engines: {node: '>=16.20.0'}
|
engines: {node: '>=16.20.0'}
|
||||||
@@ -2712,6 +2736,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==}
|
resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
process-warning@5.1.0:
|
||||||
|
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -2738,6 +2765,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4:
|
||||||
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
range-parser@1.3.0:
|
range-parser@1.3.0:
|
||||||
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2763,6 +2793,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
real-require@1.0.0:
|
||||||
|
resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==}
|
||||||
|
|
||||||
recast@0.23.21:
|
recast@0.23.21:
|
||||||
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
|
resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -2833,6 +2866,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0:
|
||||||
|
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
@@ -2963,6 +3000,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
solid-js: ^1.6.12
|
solid-js: ^1.6.12
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2971,6 +3011,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3088,6 +3132,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
tiny-glob@0.2.9:
|
tiny-glob@0.2.9:
|
||||||
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
|
resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
|
||||||
|
|
||||||
@@ -3887,6 +3935,8 @@ snapshots:
|
|||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
fastq: 1.20.3
|
fastq: 1.20.3
|
||||||
|
|
||||||
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4408,6 +4458,8 @@ snapshots:
|
|||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
atomic-sleep@1.0.0: {}
|
||||||
|
|
||||||
atomically@1.7.0: {}
|
atomically@1.7.0: {}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
@@ -5899,6 +5951,8 @@ snapshots:
|
|||||||
define-properties: 1.2.1
|
define-properties: 1.2.1
|
||||||
es-object-atoms: 1.1.2
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
on-exit-leak-free@2.1.2: {}
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
@@ -6026,6 +6080,26 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.7: {}
|
picomatch@4.0.7: {}
|
||||||
|
|
||||||
|
pino-abstract-transport@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
|
pino-std-serializers@7.1.0: {}
|
||||||
|
|
||||||
|
pino@10.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@pinojs/redact': 0.4.0
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
on-exit-leak-free: 2.1.2
|
||||||
|
pino-abstract-transport: 3.0.0
|
||||||
|
pino-std-serializers: 7.1.0
|
||||||
|
process-warning: 5.1.0
|
||||||
|
quick-format-unescaped: 4.0.4
|
||||||
|
real-require: 1.0.0
|
||||||
|
safe-stable-stringify: 2.5.0
|
||||||
|
sonic-boom: 4.2.1
|
||||||
|
thread-stream: 4.2.0
|
||||||
|
|
||||||
pkce-challenge@5.0.1: {}
|
pkce-challenge@5.0.1: {}
|
||||||
|
|
||||||
pkg-up@3.1.0:
|
pkg-up@3.1.0:
|
||||||
@@ -6061,6 +6135,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
parse-ms: 4.0.0
|
parse-ms: 4.0.0
|
||||||
|
|
||||||
|
process-warning@5.1.0: {}
|
||||||
|
|
||||||
prompts@2.4.2:
|
prompts@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
kleur: 3.0.3
|
kleur: 3.0.3
|
||||||
@@ -6088,6 +6164,8 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
range-parser@1.3.0: {}
|
range-parser@1.3.0: {}
|
||||||
|
|
||||||
raw-body@3.0.2:
|
raw-body@3.0.2:
|
||||||
@@ -6110,6 +6188,8 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.8: {}
|
react@19.2.8: {}
|
||||||
|
|
||||||
|
real-require@1.0.0: {}
|
||||||
|
|
||||||
recast@0.23.21:
|
recast@0.23.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
ast-types: 0.16.3
|
ast-types: 0.16.3
|
||||||
@@ -6203,6 +6283,8 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
|
safe-stable-stringify@2.5.0: {}
|
||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
@@ -6414,10 +6496,16 @@ snapshots:
|
|||||||
'@solid-primitives/transition-group': 1.1.2(solid-js@1.9.15)
|
'@solid-primitives/transition-group': 1.1.2(solid-js@1.9.15)
|
||||||
solid-js: 1.9.15
|
solid-js: 1.9.15
|
||||||
|
|
||||||
|
sonic-boom@4.2.1:
|
||||||
|
dependencies:
|
||||||
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
stdin-discarder@0.2.2: {}
|
stdin-discarder@0.2.2: {}
|
||||||
@@ -6546,6 +6634,10 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.3: {}
|
tapable@2.3.3: {}
|
||||||
|
|
||||||
|
thread-stream@4.2.0:
|
||||||
|
dependencies:
|
||||||
|
real-require: 1.0.0
|
||||||
|
|
||||||
tiny-glob@0.2.9:
|
tiny-glob@0.2.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
globalyzer: 0.1.0
|
globalyzer: 0.1.0
|
||||||
|
|||||||
@@ -2,7 +2,17 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth";
|
import { SESSION_COOKIE, SESSION_MAX_AGE } from "@/constants/auth";
|
||||||
import { jsonError, verifyAccessToken } from "@/server/auth";
|
import {
|
||||||
|
getRateLimitKey,
|
||||||
|
jsonError,
|
||||||
|
rejectIfLocked,
|
||||||
|
reportFailedTokens,
|
||||||
|
verifyAccessToken
|
||||||
|
} from "@/server/auth";
|
||||||
|
import { createLogger } from "@/server/logger";
|
||||||
|
import { clearFailures } from "@/server/rate-limit";
|
||||||
|
|
||||||
|
const log = createLogger("auth");
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,9 +23,19 @@ const isHttps = (req: NextRequest) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
const ip = getRateLimitKey(req);
|
||||||
|
const locked = rejectIfLocked(ip);
|
||||||
|
if (locked) return locked;
|
||||||
|
|
||||||
const body = await req.json().catch(() => ({}));
|
const body = await req.json().catch(() => ({}));
|
||||||
const token = typeof body.token === "string" ? body.token.trim() : "";
|
const token = typeof body.token === "string" ? body.token.trim() : "";
|
||||||
if (!verifyAccessToken(token)) return jsonError(401, "访问密钥不正确");
|
if (!verifyAccessToken(token)) {
|
||||||
|
if (token) reportFailedTokens(ip, [token]);
|
||||||
|
log.warn({ ip }, "登录失败");
|
||||||
|
return jsonError(401, "访问密钥不正确");
|
||||||
|
}
|
||||||
|
clearFailures(ip);
|
||||||
|
log.info({ ip }, "登录成功");
|
||||||
|
|
||||||
(await cookies()).set(SESSION_COOKIE, token, {
|
(await cookies()).set(SESSION_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
// 转码播放列表接口:浏览器解不了的视频由 TorBox 转码为 HLS
|
// 转码播放列表接口:浏览器解不了的视频由 TorBox 转码为 HLS
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { guard, jsonError } from "@/server/auth";
|
import { guard, jsonError } from "@/server/auth";
|
||||||
|
import { createLogger, mediaRef } from "@/server/logger";
|
||||||
import { createStream, isSourceType } from "@/server/torbox";
|
import { createStream, isSourceType } from "@/server/torbox";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const log = createLogger("hls");
|
||||||
|
|
||||||
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
|
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,14 +24,22 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
|||||||
return jsonError(404, "unknown source");
|
return jsonError(404, "unknown source");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const media = mediaRef(type, id, fileId);
|
||||||
|
log.info(media, "转码播放请求");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playlistUrl = await createStream(type, id, fileId);
|
const playlistUrl = await createStream(type, id, fileId);
|
||||||
const upstream = await fetch(playlistUrl, {
|
const upstream = await fetch(playlistUrl, {
|
||||||
signal: req.signal,
|
signal: req.signal,
|
||||||
cache: "no-store"
|
cache: "no-store"
|
||||||
});
|
});
|
||||||
if (!upstream.ok)
|
if (!upstream.ok) {
|
||||||
|
log.warn(
|
||||||
|
{ ...media, upstreamStatus: upstream.status },
|
||||||
|
"转码列表请求失败"
|
||||||
|
);
|
||||||
return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`);
|
return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
// 相对地址按列表地址解析,不带列表地址上的查询参数(即 API Key)
|
// 相对地址按列表地址解析,不带列表地址上的查询参数(即 API Key)
|
||||||
const playlist = (await upstream.text())
|
const playlist = (await upstream.text())
|
||||||
@@ -48,6 +59,8 @@ export async function GET(req: NextRequest, { params }: Ctx) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (req.signal.aborted) log.debug(media, "客户端中断请求");
|
||||||
|
else log.error({ ...media, err: error }, "转码播放失败");
|
||||||
return jsonError(
|
return jsonError(
|
||||||
502,
|
502,
|
||||||
error instanceof Error ? error.message : "upstream error"
|
error instanceof Error ? error.message : "upstream error"
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// 资源列表接口
|
// 资源列表接口
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { guard, jsonError } from "@/server/auth";
|
import { guard, jsonError } from "@/server/auth";
|
||||||
|
import { createLogger } from "@/server/logger";
|
||||||
import { getLibrary } from "@/server/torbox";
|
import { getLibrary } from "@/server/torbox";
|
||||||
|
|
||||||
|
const log = createLogger("library");
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -11,11 +14,23 @@ export async function GET(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
// ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存
|
// ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存
|
||||||
const bypassCache = req.nextUrl.searchParams.get("fresh") === "1";
|
const bypassCache = req.nextUrl.searchParams.get("fresh") === "1";
|
||||||
|
const data = await getLibrary({ bypassCache });
|
||||||
|
const failedSources = Object.keys(data.errors);
|
||||||
|
log[failedSources.length ? "warn" : "info"](
|
||||||
|
{
|
||||||
|
bypassCache,
|
||||||
|
itemCount: data.items.length,
|
||||||
|
fileCount: data.items.reduce((sum, item) => sum + item.files.length, 0),
|
||||||
|
failedSources
|
||||||
|
},
|
||||||
|
"资源列表"
|
||||||
|
);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ success: true, data: await getLibrary({ bypassCache }) },
|
{ success: true, data },
|
||||||
{ headers: { "Cache-Control": "no-store" } }
|
{ headers: { "Cache-Control": "no-store" } }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
log.error({ err: error }, "资源列表获取失败");
|
||||||
return jsonError(
|
return jsonError(
|
||||||
502,
|
502,
|
||||||
error instanceof Error ? error.message : "upstream error"
|
error instanceof Error ? error.message : "upstream error"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// M3U 播放列表接口
|
// M3U 播放列表接口
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { guard, jsonError } from "@/server/auth";
|
import { guard, jsonError } from "@/server/auth";
|
||||||
|
import { createLogger } from "@/server/logger";
|
||||||
import { getLibrary } from "@/server/torbox";
|
import { getLibrary } from "@/server/torbox";
|
||||||
import { buildStreamUrl } from "@/utils/media";
|
import { buildStreamUrl } from "@/utils/media";
|
||||||
|
|
||||||
|
const log = createLogger("playlist");
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -14,6 +17,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const { origin, searchParams } = new URL(req.url);
|
const { origin, searchParams } = new URL(req.url);
|
||||||
const token = searchParams.get("token") ?? "";
|
const token = searchParams.get("token") ?? "";
|
||||||
const lines = ["#EXTM3U"];
|
const lines = ["#EXTM3U"];
|
||||||
|
let entryCount = 0;
|
||||||
// 只收录可播放的资源,下载中或已失效的文件放进播放列表也无法播放
|
// 只收录可播放的资源,下载中或已失效的文件放进播放列表也无法播放
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.status !== "ready") continue;
|
if (item.status !== "ready") continue;
|
||||||
@@ -22,12 +26,14 @@ export async function GET(req: NextRequest) {
|
|||||||
/[\r\n,]/g,
|
/[\r\n,]/g,
|
||||||
" "
|
" "
|
||||||
);
|
);
|
||||||
|
entryCount += 1;
|
||||||
lines.push(
|
lines.push(
|
||||||
`#EXTINF:-1,${title}`,
|
`#EXTINF:-1,${title}`,
|
||||||
buildStreamUrl(origin, file.playUrl, file.name, token)
|
buildStreamUrl(origin, file.playUrl, file.name, token)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log.info({ itemCount: items.length, entryCount }, "播放列表");
|
||||||
return new Response(lines.join("\r\n") + "\r\n", {
|
return new Response(lines.join("\r\n") + "\r\n", {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "audio/x-mpegurl; charset=utf-8",
|
"Content-Type": "audio/x-mpegurl; charset=utf-8",
|
||||||
@@ -35,6 +41,7 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
log.error({ err: error }, "播放列表生成失败");
|
||||||
return jsonError(
|
return jsonError(
|
||||||
502,
|
502,
|
||||||
error instanceof Error ? error.message : "upstream error"
|
error instanceof Error ? error.message : "upstream error"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { guard, jsonError } from "@/server/auth";
|
import { guard, jsonError } from "@/server/auth";
|
||||||
import { isMockMode } from "@/server/env";
|
import { isMockMode } from "@/server/env";
|
||||||
|
import { createLogger, mediaRef } from "@/server/logger";
|
||||||
import { getMockImage } from "@/server/mock-library";
|
import { getMockImage } from "@/server/mock-library";
|
||||||
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
|
import { isSourceType, requestDownloadUrl } from "@/server/torbox";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ const PASS_HEADERS = [
|
|||||||
"etag"
|
"etag"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const log = createLogger("stream");
|
||||||
|
|
||||||
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
|
type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> };
|
||||||
|
|
||||||
const proxy = async (
|
const proxy = async (
|
||||||
@@ -44,9 +47,14 @@ const proxy = async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const media = mediaRef(type, id, fileId);
|
||||||
|
const range = req.headers.get("range");
|
||||||
|
// 拖动进度会产生大量 Range 请求,只有新开始的播放记为 info
|
||||||
|
const isStart = !range || /^bytes=0-/.test(range);
|
||||||
|
log[isStart ? "info" : "debug"]({ ...media, method, range }, "播放请求");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = await requestDownloadUrl(type, id, fileId);
|
const url = await requestDownloadUrl(type, id, fileId);
|
||||||
const range = req.headers.get("range");
|
|
||||||
const upstream = await fetch(url, {
|
const upstream = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: range ? { Range: range } : undefined,
|
headers: range ? { Range: range } : undefined,
|
||||||
@@ -54,6 +62,12 @@ const proxy = async (
|
|||||||
cache: "no-store"
|
cache: "no-store"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!upstream.ok)
|
||||||
|
log.warn(
|
||||||
|
{ ...media, method, upstreamStatus: upstream.status },
|
||||||
|
"上游返回异常"
|
||||||
|
);
|
||||||
|
|
||||||
const headers = new Headers({ "Cache-Control": "no-store" });
|
const headers = new Headers({ "Cache-Control": "no-store" });
|
||||||
for (const name of PASS_HEADERS) {
|
for (const name of PASS_HEADERS) {
|
||||||
const value = upstream.headers.get(name);
|
const value = upstream.headers.get(name);
|
||||||
@@ -64,6 +78,9 @@ const proxy = async (
|
|||||||
headers
|
headers
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 播放器关闭或跳转时会主动断开,不算故障
|
||||||
|
if (req.signal.aborted) log.debug(media, "客户端中断请求");
|
||||||
|
else log.error({ ...media, method, err: error }, "播放失败");
|
||||||
return jsonError(
|
return jsonError(
|
||||||
502,
|
502,
|
||||||
error instanceof Error ? error.message : "upstream error"
|
error instanceof Error ? error.message : "upstream error"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -11,3 +11,9 @@ export const LOGIN_API = "/auth/login";
|
|||||||
|
|
||||||
/** 演示模式(未配置 TORBOX_API_KEY)且未配置 ACCESS_TOKEN 时使用的访问密钥 */
|
/** 演示模式(未配置 TORBOX_API_KEY)且未配置 ACCESS_TOKEN 时使用的访问密钥 */
|
||||||
export const MOCK_ACCESS_TOKEN = "mock";
|
export const MOCK_ACCESS_TOKEN = "mock";
|
||||||
|
|
||||||
|
/** 同一 IP 累计猜错这么多个不同的密钥后锁定 */
|
||||||
|
export const AUTH_MAX_FAILURES = 5;
|
||||||
|
|
||||||
|
/** 失败计数的统计窗口,也是锁定时长(毫秒):窗口内猜错达到上限后锁满一个窗口 */
|
||||||
|
export const AUTH_LOCK_MS = 15 * 60 * 1000;
|
||||||
|
|||||||
+51
-10
@@ -3,6 +3,10 @@ import { timingSafeEqual } from "node:crypto";
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { SESSION_COOKIE } from "@/constants/auth";
|
import { SESSION_COOKIE } from "@/constants/auth";
|
||||||
import { getAccessToken } from "@/server/env";
|
import { getAccessToken } from "@/server/env";
|
||||||
|
import { createLogger, getClientIp } from "@/server/logger";
|
||||||
|
import { getLockSeconds, recordFailure } from "@/server/rate-limit";
|
||||||
|
|
||||||
|
const log = createLogger("auth");
|
||||||
|
|
||||||
const safeEqual = (a: string, b: string) => {
|
const safeEqual = (a: string, b: string) => {
|
||||||
const x = Buffer.from(a);
|
const x = Buffer.from(a);
|
||||||
@@ -18,25 +22,62 @@ export const verifyAccessToken = (
|
|||||||
return Boolean(value && expected) && safeEqual(value!, expected!);
|
return Boolean(value && expected) && safeEqual(value!, expected!);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */
|
/** 请求携带的全部密钥:登录 cookie、Bearer 头、?token=(供 PotPlayer 等外部播放器使用) */
|
||||||
export const isAuthorized = (req: NextRequest) => {
|
const getPresentedTokens = (req: NextRequest) => {
|
||||||
const header = req.headers.get("authorization") ?? "";
|
const header = req.headers.get("authorization") ?? "";
|
||||||
const bearer = header.startsWith("Bearer ") ? header.slice(7) : "";
|
return [
|
||||||
return (
|
req.cookies.get(SESSION_COOKIE)?.value,
|
||||||
verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value) ||
|
header.startsWith("Bearer ") ? header.slice(7) : undefined,
|
||||||
verifyAccessToken(bearer) ||
|
req.nextUrl.searchParams.get("token")
|
||||||
verifyAccessToken(req.nextUrl.searchParams.get("token"))
|
].filter((value): value is string => Boolean(value));
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 登录 cookie、Bearer 头或 ?token= 任一有效即通过 */
|
||||||
|
export const isAuthorized = (req: NextRequest) =>
|
||||||
|
getPresentedTokens(req).some(verifyAccessToken);
|
||||||
|
|
||||||
export const jsonError = (status: number, detail: string) =>
|
export const jsonError = (status: number, detail: string) =>
|
||||||
Response.json(
|
Response.json(
|
||||||
{ success: false, detail },
|
{ success: false, detail },
|
||||||
{ status, headers: { "Cache-Control": "no-store" } }
|
{ status, headers: { "Cache-Control": "no-store" } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 限流用的客户端标识;取不到时所有来源共用一个标识 */
|
||||||
|
export const getRateLimitKey = (req: NextRequest) =>
|
||||||
|
getClientIp(req) ?? "unknown";
|
||||||
|
|
||||||
|
/** 该来源已被锁定时返回 429,否则返回 null;锁定期间不再校验密钥,猜对也不放行 */
|
||||||
|
export const rejectIfLocked = (key: string) => {
|
||||||
|
const seconds = getLockSeconds(key);
|
||||||
|
if (!seconds) return null;
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
detail: `尝试次数过多,请 ${Math.ceil(seconds / 60)} 分钟后再试`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { "Cache-Control": "no-store", "Retry-After": String(seconds) }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 记录猜错的密钥,达到上限时写一条锁定日志 */
|
||||||
|
export const reportFailedTokens = (key: string, tokens: string[]) => {
|
||||||
|
if (tokens.some((token) => recordFailure(key, token)))
|
||||||
|
log.warn({ ip: key }, "密钥错误次数过多,已锁定");
|
||||||
|
};
|
||||||
|
|
||||||
/** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */
|
/** 统一入口:返回 Response 表示已拒绝,返回 null 表示通过 */
|
||||||
export const guard = (req: NextRequest) => {
|
export const guard = (req: NextRequest) => {
|
||||||
if (!isAuthorized(req)) return jsonError(401, "bad token");
|
const key = getRateLimitKey(req);
|
||||||
return null;
|
const locked = rejectIfLocked(key);
|
||||||
|
if (locked) return locked;
|
||||||
|
|
||||||
|
if (isAuthorized(req)) return null;
|
||||||
|
// 没带任何密钥的请求(如登录态过期的页面)不是猜测,不计入失败
|
||||||
|
reportFailedTokens(key, getPresentedTokens(req));
|
||||||
|
// 不记录路径:流地址末尾可能带文件名
|
||||||
|
log.warn({ method: req.method, ip: key }, "未授权访问");
|
||||||
|
return jsonError(401, "bad token");
|
||||||
};
|
};
|
||||||
|
|||||||
+24
-1
@@ -1,7 +1,12 @@
|
|||||||
// 服务端环境变量读取与校验
|
// 服务端环境变量读取与校验
|
||||||
import { MOCK_ACCESS_TOKEN } from "@/constants/auth";
|
import { MOCK_ACCESS_TOKEN } from "@/constants/auth";
|
||||||
|
|
||||||
type ServerEnvName = "ACCESS_TOKEN" | "TORBOX_API_KEY";
|
type ServerEnvName =
|
||||||
|
| "ACCESS_TOKEN"
|
||||||
|
| "TORBOX_API_KEY"
|
||||||
|
| "LOG_LEVEL"
|
||||||
|
| "LOG_DIR"
|
||||||
|
| "LOG_RETAIN_DAYS";
|
||||||
|
|
||||||
/** 读取环境变量,空白视为未配置 */
|
/** 读取环境变量,空白视为未配置 */
|
||||||
const readEnv = (name: ServerEnvName) => {
|
const readEnv = (name: ServerEnvName) => {
|
||||||
@@ -45,3 +50,21 @@ export const getTorboxApiKey = () => {
|
|||||||
if (!key) throw new Error("未配置 TORBOX_API_KEY");
|
if (!key) throw new Error("未配置 TORBOX_API_KEY");
|
||||||
return key;
|
return key;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 日志级别(LOG_LEVEL),未配置时由日志模块按运行环境决定 */
|
||||||
|
export const getLogLevel = () => readEnv("LOG_LEVEL")?.trim().toLowerCase();
|
||||||
|
|
||||||
|
/** 日志文件目录(LOG_DIR),未配置时日志只输出到控制台 */
|
||||||
|
export const getLogDir = () => readEnv("LOG_DIR")?.trim();
|
||||||
|
|
||||||
|
/** 日志文件保留天数(LOG_RETAIN_DAYS),默认 14 天 */
|
||||||
|
export const getLogRetainDays = () => {
|
||||||
|
const days = Number(readEnv("LOG_RETAIN_DAYS"));
|
||||||
|
return Number.isInteger(days) && days > 0 ? days : 14;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 实际配置的密钥值,供日志脱敏使用;演示模式的固定密钥不是机密,不在其中 */
|
||||||
|
export const getSecretValues = () =>
|
||||||
|
[readEnv("ACCESS_TOKEN"), readEnv("TORBOX_API_KEY")].filter(
|
||||||
|
(value): value is string => Boolean(value)
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// 日志落盘:按天写入目录下的 torbox-admin-YYYY-MM-DD.log,并清理超过保留天数的旧文件
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const FILE_PREFIX = "torbox-admin-";
|
||||||
|
const FILE_PATTERN = /^torbox-admin-(\d{4}-\d{2}-\d{2})\.log$/;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** 本地时区的日期,与容器 TZ 一致,文件名和日志里的日期口径相同 */
|
||||||
|
const formatDay = (date: Date) =>
|
||||||
|
[
|
||||||
|
date.getFullYear(),
|
||||||
|
String(date.getMonth() + 1).padStart(2, "0"),
|
||||||
|
String(date.getDate()).padStart(2, "0")
|
||||||
|
].join("-");
|
||||||
|
|
||||||
|
const pruneOldFiles = (dir: string, retainDays: number) => {
|
||||||
|
const threshold = formatDay(new Date(Date.now() - retainDays * DAY_MS));
|
||||||
|
for (const file of fs.readdirSync(dir)) {
|
||||||
|
const day = FILE_PATTERN.exec(file)?.[1];
|
||||||
|
// 日期串字典序即时间序
|
||||||
|
if (day && day < threshold) fs.rmSync(path.join(dir, file), { force: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建按天切分的日志文件写入器。写入失败(目录无权限、磁盘满等)只在 stderr 提示一次,
|
||||||
|
* 不抛出,避免日志问题拖垮业务请求。
|
||||||
|
*/
|
||||||
|
export const createLogFileWriter = (dir: string, retainDays: number) => {
|
||||||
|
let currentDay = "";
|
||||||
|
let stream: fs.WriteStream | undefined;
|
||||||
|
let failed = false;
|
||||||
|
|
||||||
|
const fail = (error: unknown) => {
|
||||||
|
if (failed) return;
|
||||||
|
failed = true;
|
||||||
|
console.error(
|
||||||
|
`日志文件不可用,仅输出到控制台:${error instanceof Error ? error.message : error}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rotate = (day: string) => {
|
||||||
|
stream?.end();
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
pruneOldFiles(dir, retainDays);
|
||||||
|
stream = fs.createWriteStream(path.join(dir, `${FILE_PREFIX}${day}.log`), {
|
||||||
|
flags: "a"
|
||||||
|
});
|
||||||
|
stream.on("error", fail);
|
||||||
|
currentDay = day;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (line: string) => {
|
||||||
|
if (failed) return;
|
||||||
|
try {
|
||||||
|
const day = formatDay(new Date());
|
||||||
|
if (day !== currentDay) rotate(day);
|
||||||
|
stream?.write(line);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// 服务端日志(pino)
|
||||||
|
//
|
||||||
|
// 脱敏分三层,任何一层都不依赖调用方自觉:
|
||||||
|
// 1. 类型约束:媒体资源只能通过 mediaRef 记录「来源 + 父 id(itemId)+ 子 id(fileId)」,不记录文件名
|
||||||
|
// 2. redact:字段名为 token、authorization、cookie、各类文件名的值一律替换为 [REDACTED]
|
||||||
|
// 3. 输出前兜底:整行输出里出现的真实密钥值、URL 查询串里的 token= 等,在写出前统一替换
|
||||||
|
import pino from "pino";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import {
|
||||||
|
getLogDir,
|
||||||
|
getLogLevel,
|
||||||
|
getLogRetainDays,
|
||||||
|
getSecretValues
|
||||||
|
} from "@/server/env";
|
||||||
|
import { createLogFileWriter } from "@/server/log-file";
|
||||||
|
import type { SourceType } from "@/types/torbox";
|
||||||
|
|
||||||
|
const REDACTED = "[REDACTED]";
|
||||||
|
|
||||||
|
/** 这些字段名(顶层或第一层嵌套)的值不会出现在日志里 */
|
||||||
|
const REDACT_KEYS = [
|
||||||
|
"token",
|
||||||
|
"apiKey",
|
||||||
|
"authorization",
|
||||||
|
"password",
|
||||||
|
"cookie",
|
||||||
|
"name",
|
||||||
|
"fileName",
|
||||||
|
"short_name"
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 对 JSON 行内的转义形式也要匹配,所以同时准备原文、JSON 转义和 URL 编码三种写法 */
|
||||||
|
const secretForms = (secret: string) => [
|
||||||
|
...new Set([
|
||||||
|
secret,
|
||||||
|
JSON.stringify(secret).slice(1, -1),
|
||||||
|
encodeURIComponent(secret)
|
||||||
|
])
|
||||||
|
];
|
||||||
|
|
||||||
|
/** URL 查询串或表单里的敏感参数,如 ?token=xxx、&api_key=xxx */
|
||||||
|
const SECRET_PARAM = /\b(token|api_?key|authorization|password)=[^&\s"'\\]+/gi;
|
||||||
|
const BEARER = /\bBearer\s+[^\s"'\\]+/gi;
|
||||||
|
|
||||||
|
/** 写出前的最后一道防线:即使字段名没命中,也不让真实密钥落盘 */
|
||||||
|
const scrubLine = (line: string) => {
|
||||||
|
let out = line;
|
||||||
|
for (const secret of getSecretValues())
|
||||||
|
for (const form of secretForms(secret))
|
||||||
|
out = out.split(form).join(REDACTED);
|
||||||
|
return out
|
||||||
|
.replace(SECRET_PARAM, `$1=${REDACTED}`)
|
||||||
|
.replace(BEARER, `Bearer ${REDACTED}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveLevel = () => {
|
||||||
|
const level = getLogLevel();
|
||||||
|
if (level && (level === "silent" || level in pino.levels.values))
|
||||||
|
return level;
|
||||||
|
return process.env.NODE_ENV === "production" ? "info" : "debug";
|
||||||
|
};
|
||||||
|
|
||||||
|
const logDir = getLogDir();
|
||||||
|
const writeFile = logDir
|
||||||
|
? createLogFileWriter(logDir, getLogRetainDays())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
/** 控制台与日志文件都经过 scrubLine,写什么就是什么 */
|
||||||
|
const write = (line: string) => {
|
||||||
|
const safe = scrubLine(line);
|
||||||
|
process.stdout.write(safe);
|
||||||
|
writeFile?.(safe);
|
||||||
|
};
|
||||||
|
|
||||||
|
const root = pino(
|
||||||
|
{
|
||||||
|
level: resolveLevel(),
|
||||||
|
base: null,
|
||||||
|
timestamp: pino.stdTimeFunctions.isoTime,
|
||||||
|
formatters: { level: (label) => ({ level: label }) },
|
||||||
|
redact: {
|
||||||
|
paths: REDACT_KEYS.flatMap((key) => [key, `*.${key}`]),
|
||||||
|
censor: REDACTED
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ write }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 按模块创建日志器,输出里带 module 字段,如 createLogger("stream") */
|
||||||
|
export const createLogger = (module: string) => root.child({ module });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体资源的日志标识:来源 + 父 id(LibraryItem.id)+ 子 id(LibraryFile.id)。
|
||||||
|
* 日志里只用它定位资源,不记录资源名、文件名、播放地址(地址末尾可能带文件名和 token)。
|
||||||
|
*/
|
||||||
|
export const mediaRef = (
|
||||||
|
source: SourceType,
|
||||||
|
itemId: number | string,
|
||||||
|
fileId?: number | string
|
||||||
|
) => ({
|
||||||
|
source,
|
||||||
|
itemId: Number(itemId),
|
||||||
|
...(fileId === undefined ? {} : { fileId: Number(fileId) })
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端 IP:取 x-forwarded-for 最后一项,即最近一层反向代理看到的来源。
|
||||||
|
* 前面的项是客户端自己填的,可以随意伪造,不能用于限流;因此服务必须部署在一层反向代理之后。
|
||||||
|
*/
|
||||||
|
export const getClientIp = (req: NextRequest) =>
|
||||||
|
req.headers.get("x-forwarded-for")?.split(",").at(-1)?.trim() ||
|
||||||
|
req.headers.get("x-real-ip") ||
|
||||||
|
undefined;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 访问密钥防爆破:按客户端 IP 统计猜错的密钥,达到上限后锁定
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { AUTH_LOCK_MS, AUTH_MAX_FAILURES } from "@/constants/auth";
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
/** 本窗口内猜错过的不同密钥(哈希),同一个错误密钥重复提交只算一次 */
|
||||||
|
attempts: Set<string>;
|
||||||
|
/** 窗口结束时间;达到上限后改为锁定结束时间 */
|
||||||
|
resetAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 记录的 IP 数量上限,防止伪造大量来源撑爆内存 */
|
||||||
|
const MAX_ENTRIES = 10_000;
|
||||||
|
|
||||||
|
// 挂在 globalThis 上,避免各路由打包后各持一份计数
|
||||||
|
const STORE_KEY = Symbol.for("torbox-admin.auth-failures");
|
||||||
|
const globals = globalThis as { [STORE_KEY]?: Map<string, Entry> };
|
||||||
|
const store = (globals[STORE_KEY] ??= new Map<string, Entry>());
|
||||||
|
|
||||||
|
const hash = (value: string) =>
|
||||||
|
createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||||
|
|
||||||
|
const getEntry = (ip: string) => {
|
||||||
|
const entry = store.get(ip);
|
||||||
|
if (entry && entry.resetAt <= Date.now()) {
|
||||||
|
store.delete(ip);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 距解锁还有多少秒;未锁定返回 0 */
|
||||||
|
export const getLockSeconds = (ip: string) => {
|
||||||
|
const entry = getEntry(ip);
|
||||||
|
if (!entry || entry.attempts.size < AUTH_MAX_FAILURES) return 0;
|
||||||
|
return Math.ceil((entry.resetAt - Date.now()) / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次猜错的密钥,返回此次是否触发了锁定。
|
||||||
|
* 按不同密钥计数:登录态过期的浏览器会反复带着同一个旧 cookie 请求,不应被当成爆破;
|
||||||
|
* 而爆破必然不断更换猜测值。
|
||||||
|
*/
|
||||||
|
export const recordFailure = (ip: string, attempt: string) => {
|
||||||
|
const now = Date.now();
|
||||||
|
let entry = getEntry(ip);
|
||||||
|
if (!entry) {
|
||||||
|
if (store.size >= MAX_ENTRIES) {
|
||||||
|
for (const [key, value] of store)
|
||||||
|
if (value.resetAt <= now) store.delete(key);
|
||||||
|
// 仍然超限则丢弃最早记录的来源
|
||||||
|
if (store.size >= MAX_ENTRIES)
|
||||||
|
store.delete(store.keys().next().value as string);
|
||||||
|
}
|
||||||
|
entry = { attempts: new Set(), resetAt: now + AUTH_LOCK_MS };
|
||||||
|
store.set(ip, entry);
|
||||||
|
}
|
||||||
|
if (entry.attempts.size >= AUTH_MAX_FAILURES) return false;
|
||||||
|
|
||||||
|
entry.attempts.add(hash(attempt));
|
||||||
|
if (entry.attempts.size < AUTH_MAX_FAILURES) return false;
|
||||||
|
entry.resetAt = now + AUTH_LOCK_MS;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 登录成功后清除该 IP 的失败记录 */
|
||||||
|
export const clearFailures = (ip: string) => {
|
||||||
|
store.delete(ip);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// TorBox API 服务端封装
|
// TorBox API 服务端封装
|
||||||
import { getTorboxApiKey, isMockMode } from "@/server/env";
|
import { getTorboxApiKey, isMockMode } from "@/server/env";
|
||||||
|
import { createLogger } from "@/server/logger";
|
||||||
import { getMockLibrary } from "@/server/mock-library";
|
import { getMockLibrary } from "@/server/mock-library";
|
||||||
import { SOURCES, TORBOX_API } from "@/constants/torbox";
|
import { SOURCES, TORBOX_API } from "@/constants/torbox";
|
||||||
import type {
|
import type {
|
||||||
@@ -49,7 +50,10 @@ const toProgressRatio = (value: number | undefined) => {
|
|||||||
|
|
||||||
const apiKey = getTorboxApiKey;
|
const apiKey = getTorboxApiKey;
|
||||||
|
|
||||||
|
const log = createLogger("torbox");
|
||||||
|
|
||||||
const torboxFetch = async (path: string) => {
|
const torboxFetch = async (path: string) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
const res = await fetch(TORBOX_API + path, {
|
const res = await fetch(TORBOX_API + path, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${apiKey()}`,
|
Authorization: `Bearer ${apiKey()}`,
|
||||||
@@ -58,6 +62,15 @@ const torboxFetch = async (path: string) => {
|
|||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
cache: "no-store"
|
cache: "no-store"
|
||||||
});
|
});
|
||||||
|
// 只记录接口路径:查询串里有资源 id,requestdl 的查询串还带着 API Key
|
||||||
|
log.debug(
|
||||||
|
{
|
||||||
|
endpoint: path.split("?")[0],
|
||||||
|
status: res.status,
|
||||||
|
durationMs: Date.now() - startedAt
|
||||||
|
},
|
||||||
|
"TorBox 请求"
|
||||||
|
);
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok || body.success === false)
|
if (!res.ok || body.success === false)
|
||||||
throw new Error(body.detail ?? res.statusText);
|
throw new Error(body.detail ?? res.statusText);
|
||||||
|
|||||||
Reference in New Issue
Block a user