feat: 引入pino,添加cicd
torbox-admin / deploy (push) Successful in 2m24s

This commit is contained in:
2026-10-06 02:10:52 -07:00
parent e0c403c867
commit 8e695939ab
18 changed files with 665 additions and 22 deletions
+8
View File
@@ -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=
+138
View File
@@ -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
View File
@@ -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"]
+1
View File
@@ -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",
+92
View File
@@ -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
+22 -2
View File
@@ -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,
+14 -1
View File
@@ -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"
+16 -1
View File
@@ -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"
+7
View File
@@ -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"
@@ -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"
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+6
View File
@@ -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;
+51 -10
View File
@@ -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");
};
+24 -1
View File
@@ -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)
);
+64
View File
@@ -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);
}
};
};
+113
View File
@@ -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;
+69
View File
@@ -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);
};
+13
View File
@@ -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);