diff --git a/.env.example b/.env.example index a771231..adf45b5 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,11 @@ TORBOX_API_KEY= # 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权 # 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动 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= diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..432e8f0 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 1861874..7276d67 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ RUN npm install -g pnpm@10 COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile -# NEXT_PUBLIC_* 会在构建时内联进浏览器代码,必须通过构建参数传入 -ARG NEXT_PUBLIC_API_BASE_URL +# NEXT_PUBLIC_* 会在构建时内联进浏览器代码;默认同源 /api,不能留空(空串不会触发代码里的默认值) +ARG NEXT_PUBLIC_API_BASE_URL=/api ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \ NEXT_TELEMETRY_DISABLED=1 @@ -20,7 +20,8 @@ FROM node:22-alpine ENV NODE_ENV=production \ NEXT_TELEMETRY_DISABLED=1 \ HOSTNAME=127.0.0.1 \ - PORT=22786 + PORT=22787 \ + TZ=Asia/Shanghai WORKDIR /app @@ -30,9 +31,11 @@ COPY --from=builder --chown=node:node /app/public ./public USER node -EXPOSE 22786 +EXPOSE 22787 +# 端口可由 -e PORT 覆盖,检查必须跟随实际端口(host 网络下写死会探测到别的服务) 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"] diff --git a/package.json b/package.json index 619b195..061d701 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "lucide-react": "^1.47.0", "motion": "^14.0.0", "next": "16.3.5", + "pino": "^10.4.0", "react": "19.2.8", "react-dom": "19.2.8", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ecccaf..76243b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: next: 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) + pino: + specifier: ^10.4.0 + version: 10.4.0 react: specifier: 19.2.8 version: 19.2.8 @@ -643,6 +646,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1141,6 +1147,10 @@ packages: asynckit@0.4.0: 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: resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} engines: {node: '>=10.12.0'} @@ -2550,6 +2560,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} 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: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -2672,6 +2686,16 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} 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: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -2712,6 +2736,9 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -2738,6 +2765,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -2763,6 +2793,9 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -2833,6 +2866,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2963,6 +3000,9 @@ packages: peerDependencies: solid-js: ^1.6.12 + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2971,6 +3011,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -3088,6 +3132,10 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-glob@0.2.9: resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} @@ -3887,6 +3935,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@pinojs/redact@0.4.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4408,6 +4458,8 @@ snapshots: asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} + atomically@1.7.0: {} available-typed-arrays@1.0.7: @@ -5899,6 +5951,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -6026,6 +6080,26 @@ snapshots: 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: {} pkg-up@3.1.0: @@ -6061,6 +6135,8 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-warning@5.1.0: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -6088,6 +6164,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -6110,6 +6188,8 @@ snapshots: react@19.2.8: {} + real-require@1.0.0: {} + recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -6203,6 +6283,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} scheduler@0.27.0: {} @@ -6414,10 +6496,16 @@ snapshots: '@solid-primitives/transition-group': 1.1.2(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@0.6.1: {} + split2@4.2.0: {} + statuses@2.0.2: {} stdin-discarder@0.2.2: {} @@ -6546,6 +6634,10 @@ snapshots: tapable@2.3.3: {} + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-glob@0.2.9: dependencies: globalyzer: 0.1.0 diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 865aab1..72854b3 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -2,7 +2,17 @@ import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; 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"; @@ -13,9 +23,19 @@ const isHttps = (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 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, { httpOnly: true, diff --git a/src/app/api/hls/[type]/[id]/[fileId]/route.ts b/src/app/api/hls/[type]/[id]/[fileId]/route.ts index 14118b9..afad49b 100644 --- a/src/app/api/hls/[type]/[id]/[fileId]/route.ts +++ b/src/app/api/hls/[type]/[id]/[fileId]/route.ts @@ -1,10 +1,13 @@ // 转码播放列表接口:浏览器解不了的视频由 TorBox 转码为 HLS import type { NextRequest } from "next/server"; import { guard, jsonError } from "@/server/auth"; +import { createLogger, mediaRef } from "@/server/logger"; import { createStream, isSourceType } from "@/server/torbox"; export const dynamic = "force-dynamic"; +const log = createLogger("hls"); + 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"); } + const media = mediaRef(type, id, fileId); + log.info(media, "转码播放请求"); + try { const playlistUrl = await createStream(type, id, fileId); const upstream = await fetch(playlistUrl, { signal: req.signal, cache: "no-store" }); - if (!upstream.ok) + if (!upstream.ok) { + log.warn( + { ...media, upstreamStatus: upstream.status }, + "转码列表请求失败" + ); return jsonError(502, `TorBox 播放列表请求失败(${upstream.status})`); + } // 相对地址按列表地址解析,不带列表地址上的查询参数(即 API Key) const playlist = (await upstream.text()) @@ -48,6 +59,8 @@ export async function GET(req: NextRequest, { params }: Ctx) { } }); } catch (error) { + if (req.signal.aborted) log.debug(media, "客户端中断请求"); + else log.error({ ...media, err: error }, "转码播放失败"); return jsonError( 502, error instanceof Error ? error.message : "upstream error" diff --git a/src/app/api/library/route.ts b/src/app/api/library/route.ts index b5eb3f3..785c488 100644 --- a/src/app/api/library/route.ts +++ b/src/app/api/library/route.ts @@ -1,8 +1,11 @@ // 资源列表接口 import type { NextRequest } from "next/server"; import { guard, jsonError } from "@/server/auth"; +import { createLogger } from "@/server/logger"; import { getLibrary } from "@/server/torbox"; +const log = createLogger("library"); + export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { @@ -11,11 +14,23 @@ export async function GET(req: NextRequest) { try { // ?fresh=1:用户手动刷新,绕过 TorBox 列表缓存 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( - { success: true, data: await getLibrary({ bypassCache }) }, + { success: true, data }, { headers: { "Cache-Control": "no-store" } } ); } catch (error) { + log.error({ err: error }, "资源列表获取失败"); return jsonError( 502, error instanceof Error ? error.message : "upstream error" diff --git a/src/app/api/playlist/route.ts b/src/app/api/playlist/route.ts index 75e3809..38780c8 100644 --- a/src/app/api/playlist/route.ts +++ b/src/app/api/playlist/route.ts @@ -1,9 +1,12 @@ // M3U 播放列表接口 import type { NextRequest } from "next/server"; import { guard, jsonError } from "@/server/auth"; +import { createLogger } from "@/server/logger"; import { getLibrary } from "@/server/torbox"; import { buildStreamUrl } from "@/utils/media"; +const log = createLogger("playlist"); + export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { @@ -14,6 +17,7 @@ export async function GET(req: NextRequest) { const { origin, searchParams } = new URL(req.url); const token = searchParams.get("token") ?? ""; const lines = ["#EXTM3U"]; + let entryCount = 0; // 只收录可播放的资源,下载中或已失效的文件放进播放列表也无法播放 for (const item of items) { if (item.status !== "ready") continue; @@ -22,12 +26,14 @@ export async function GET(req: NextRequest) { /[\r\n,]/g, " " ); + entryCount += 1; lines.push( `#EXTINF:-1,${title}`, buildStreamUrl(origin, file.playUrl, file.name, token) ); } } + log.info({ itemCount: items.length, entryCount }, "播放列表"); return new Response(lines.join("\r\n") + "\r\n", { headers: { "Content-Type": "audio/x-mpegurl; charset=utf-8", @@ -35,6 +41,7 @@ export async function GET(req: NextRequest) { } }); } catch (error) { + log.error({ err: error }, "播放列表生成失败"); return jsonError( 502, error instanceof Error ? error.message : "upstream error" diff --git a/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts b/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts index 1c9d499..6b3aada 100644 --- a/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts +++ b/src/app/api/stream/[type]/[id]/[fileId]/[[...name]]/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { guard, jsonError } from "@/server/auth"; import { isMockMode } from "@/server/env"; +import { createLogger, mediaRef } from "@/server/logger"; import { getMockImage } from "@/server/mock-library"; import { isSourceType, requestDownloadUrl } from "@/server/torbox"; @@ -17,6 +18,8 @@ const PASS_HEADERS = [ "etag" ]; +const log = createLogger("stream"); + type Ctx = { params: Promise<{ type: string; id: string; fileId: string }> }; 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 { const url = await requestDownloadUrl(type, id, fileId); - const range = req.headers.get("range"); const upstream = await fetch(url, { method, headers: range ? { Range: range } : undefined, @@ -54,6 +62,12 @@ const proxy = async ( cache: "no-store" }); + if (!upstream.ok) + log.warn( + { ...media, method, upstreamStatus: upstream.status }, + "上游返回异常" + ); + const headers = new Headers({ "Cache-Control": "no-store" }); for (const name of PASS_HEADERS) { const value = upstream.headers.get(name); @@ -64,6 +78,9 @@ const proxy = async ( headers }); } catch (error) { + // 播放器关闭或跳转时会主动断开,不算故障 + if (req.signal.aborted) log.debug(media, "客户端中断请求"); + else log.error({ ...media, method, err: error }, "播放失败"); return jsonError( 502, error instanceof Error ? error.message : "upstream error" diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..c046783 Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/constants/auth.ts b/src/constants/auth.ts index 7e37172..7072034 100644 --- a/src/constants/auth.ts +++ b/src/constants/auth.ts @@ -11,3 +11,9 @@ export const LOGIN_API = "/auth/login"; /** 演示模式(未配置 TORBOX_API_KEY)且未配置 ACCESS_TOKEN 时使用的访问密钥 */ export const MOCK_ACCESS_TOKEN = "mock"; + +/** 同一 IP 累计猜错这么多个不同的密钥后锁定 */ +export const AUTH_MAX_FAILURES = 5; + +/** 失败计数的统计窗口,也是锁定时长(毫秒):窗口内猜错达到上限后锁满一个窗口 */ +export const AUTH_LOCK_MS = 15 * 60 * 1000; diff --git a/src/server/auth.ts b/src/server/auth.ts index 71a77d1..e0fb89e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -3,6 +3,10 @@ import { timingSafeEqual } from "node:crypto"; import type { NextRequest } from "next/server"; import { SESSION_COOKIE } from "@/constants/auth"; 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 x = Buffer.from(a); @@ -18,25 +22,62 @@ export const verifyAccessToken = ( return Boolean(value && expected) && safeEqual(value!, expected!); }; -/** 校验登录 cookie、Bearer 头或 ?token=(供 PotPlayer 等外部播放器使用)任一有效 */ -export const isAuthorized = (req: NextRequest) => { +/** 请求携带的全部密钥:登录 cookie、Bearer 头、?token=(供 PotPlayer 等外部播放器使用) */ +const getPresentedTokens = (req: NextRequest) => { const header = req.headers.get("authorization") ?? ""; - const bearer = header.startsWith("Bearer ") ? header.slice(7) : ""; - return ( - verifyAccessToken(req.cookies.get(SESSION_COOKIE)?.value) || - verifyAccessToken(bearer) || - verifyAccessToken(req.nextUrl.searchParams.get("token")) - ); + return [ + req.cookies.get(SESSION_COOKIE)?.value, + header.startsWith("Bearer ") ? header.slice(7) : undefined, + 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) => Response.json( { success: false, detail }, { 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 表示通过 */ export const guard = (req: NextRequest) => { - if (!isAuthorized(req)) return jsonError(401, "bad token"); - return null; + const key = getRateLimitKey(req); + 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"); }; diff --git a/src/server/env.ts b/src/server/env.ts index 861d9c0..dfedbac 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -1,7 +1,12 @@ // 服务端环境变量读取与校验 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) => { @@ -45,3 +50,21 @@ export const getTorboxApiKey = () => { if (!key) throw new Error("未配置 TORBOX_API_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) + ); diff --git a/src/server/log-file.ts b/src/server/log-file.ts new file mode 100644 index 0000000..1bcd01e --- /dev/null +++ b/src/server/log-file.ts @@ -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); + } + }; +}; diff --git a/src/server/logger.ts b/src/server/logger.ts new file mode 100644 index 0000000..26935e7 --- /dev/null +++ b/src/server/logger.ts @@ -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; diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts new file mode 100644 index 0000000..694c145 --- /dev/null +++ b/src/server/rate-limit.ts @@ -0,0 +1,69 @@ +// 访问密钥防爆破:按客户端 IP 统计猜错的密钥,达到上限后锁定 +import { createHash } from "node:crypto"; +import { AUTH_LOCK_MS, AUTH_MAX_FAILURES } from "@/constants/auth"; + +type Entry = { + /** 本窗口内猜错过的不同密钥(哈希),同一个错误密钥重复提交只算一次 */ + attempts: Set; + /** 窗口结束时间;达到上限后改为锁定结束时间 */ + 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 }; +const store = (globals[STORE_KEY] ??= new Map()); + +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); +}; diff --git a/src/server/torbox.ts b/src/server/torbox.ts index 442aa05..2f90204 100644 --- a/src/server/torbox.ts +++ b/src/server/torbox.ts @@ -1,5 +1,6 @@ // TorBox API 服务端封装 import { getTorboxApiKey, isMockMode } from "@/server/env"; +import { createLogger } from "@/server/logger"; import { getMockLibrary } from "@/server/mock-library"; import { SOURCES, TORBOX_API } from "@/constants/torbox"; import type { @@ -49,7 +50,10 @@ const toProgressRatio = (value: number | undefined) => { const apiKey = getTorboxApiKey; +const log = createLogger("torbox"); + const torboxFetch = async (path: string) => { + const startedAt = Date.now(); const res = await fetch(TORBOX_API + path, { headers: { Authorization: `Bearer ${apiKey()}`, @@ -58,6 +62,15 @@ const torboxFetch = async (path: string) => { signal: AbortSignal.timeout(15000), 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(() => ({})); if (!res.ok || body.success === false) throw new Error(body.detail ?? res.statusText);