From 4efa3401ca380b4b1c5962879726803da3782782 Mon Sep 17 00:00:00 2001 From: JetSprow Date: Thu, 30 Apr 2026 09:18:05 +1000 Subject: [PATCH] feat: remove redis service from lite deployment --- .env.example | 3 -- README.md | 17 +++---- docker-compose.yml | 19 -------- package-lock.json | 80 +-------------------------------- package.json | 1 - scripts/install-jboard-panel.sh | 9 +--- src/lib/rate-limit.ts | 57 ++++++++++------------- src/lib/redis.ts | 36 --------------- 8 files changed, 33 insertions(+), 189 deletions(-) delete mode 100644 src/lib/redis.ts diff --git a/.env.example b/.env.example index 01ee4b5..045faa3 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,6 @@ SUBSCRIPTION_URL="" # Must be at least 32 bytes, used for AES-256-GCM encryption ENCRYPTION_KEY="0123456789abcdef0123456789abcdef" -# Redis connection URL for local tools; Docker Compose overrides host to redis -REDIS_URL="redis://localhost:6379" - # Optional GeoIP MMDB path. The repository includes a default City database at data/GeoLite2-City.mmdb. GEOIP_MMDB_PATH="data/GeoLite2-City.mmdb" diff --git a/README.md b/README.md index b086cd0..35dcb14 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ J-Board 的定位很明确:它不是新的节点控制面,也不替代 3x-ui 用户浏览器 / 客户端订阅 ↓ Next.js App Router 面板 - ├─ PostgreSQL:用户、订单、套餐、订阅、审计、风控事件 - ├─ Redis:限流、后台任务与缓存辅助 + ├─ SQLite:用户、订单、套餐、订阅、审计、风控事件 + ├─ 进程内限流:无需 Redis 单独容器 ├─ 3x-ui API:同步入站、开通/暂停/删除代理客户端 └─ Agent API:接收 jboard-agent 上报的延迟、路由、节点真实连接日志 @@ -73,9 +73,8 @@ J-Board 面板和 Agent 使用相对独立的版本节奏。 ## 技术栈 - Next.js 16 App Router + React 19 -- Prisma 7 + PostgreSQL 16 +- Prisma 7 + SQLite - NextAuth 4 Credentials -- Redis 7 - Tailwind CSS 4 + Base UI + Sonner + Recharts - Nodemailer SMTP 邮件 - MaxMind MMDB GeoIP @@ -118,9 +117,7 @@ J-Board 面板和 Agent 使用相对独立的版本节奏。 | `SUBSCRIPTION_URL` | 订阅访问地址 | 可选。用于生成客户端订阅链接,例如 `https://sub.example.com`;留空时复用 `NEXTAUTH_URL`。 | | `NEXTAUTH_SECRET` | 登录会话密钥 | 生产环境必须使用随机长字符串。 | | `ENCRYPTION_KEY` | 敏感信息加密密钥 | 至少 32 字节。生产使用后不要更换,否则 3x-ui 密码、探测 Token、SMTP 密码、流媒体凭据等已加密数据会无法解密。 | -| `DATABASE_URL` | PostgreSQL 连接 | 本地工具使用;Docker 部署时 Compose 会覆盖为容器内数据库地址。 | -| `POSTGRES_PASSWORD` | Docker PostgreSQL 密码 | 一键脚本会自动生成。 | -| `REDIS_URL` | Redis 连接 | 本地工具使用;Docker 部署时 Compose 会覆盖为容器内 Redis 地址。 | +| `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`;Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 | | `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码。 | @@ -144,7 +141,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board/main/script | 订阅访问地址 | 用于生成 Clash/V2rayN/Shadowrocket 等客户端订阅链接。可以和网站地址相同,也可以填独立订阅域名,例如 `https://sub.example.com`。 | | 本机监听端口 | 默认 `3000`。Nginx、Caddy、宝塔或 1Panel 的反代目标就是 `http://127.0.0.1:3000`。 | | 管理员邮箱和密码 | 首次初始化会创建该管理员,脚本完成后会再次打印。 | -| PostgreSQL 密码、`NEXTAUTH_SECRET`、`ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 | +| `NEXTAUTH_SECRET`、`ENCRYPTION_KEY` | 可手动输入;回车会自动生成安全值。 | 也可以通过环境变量覆盖默认行为: @@ -170,7 +167,7 @@ APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board BRANCH=main bash <(curl -fsSL https ```bash cp .env.example .env -# 编辑 .env,尤其是 NEXTAUTH_URL、SUBSCRIPTION_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、POSTGRES_PASSWORD、管理员账号 +# 编辑 .env,尤其是 NEXTAUTH_URL、SUBSCRIPTION_URL、NEXTAUTH_SECRET、ENCRYPTION_KEY、管理员账号 docker compose build init app docker compose --profile setup run --rm init docker compose up -d app @@ -444,7 +441,7 @@ shasum -a 256 jboard-agent-linux-amd64 jboard-agent-linux-arm64 > SHA256SUMS - 不要提交 `.env`、探测 Token、3x-ui 密码、SMTP 密码、支付密钥。 - 数据库备份包含用户、订单、支付配置、节点凭据和邮件配置,建议加密保存并限制下载权限。 -- 生产环境不要公开 PostgreSQL 和 Redis 端口。 +- 生产环境不要公开 SQLite 文件、备份文件或管理接口。 - 3x-ui 面板建议限制来源 IP 或使用反向代理鉴权。 - `ENCRYPTION_KEY` 一旦生产使用不要随意更换。 - 管理后台账号建议使用强密码和专用邮箱。 diff --git a/docker-compose.yml b/docker-compose.yml index 577c3dc..ec70637 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,15 +6,11 @@ services: restart: unless-stopped ports: - "${APP_PORT:-3000}:3000" - depends_on: - redis: - condition: service_healthy volumes: - sqlite_data:/app/storage env_file: .env environment: - DATABASE_URL=file:/app/storage/jboard.db - - REDIS_URL=redis://redis:6379 healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/public/app-info || exit 1"] interval: 30s @@ -36,20 +32,5 @@ services: profiles: - setup - redis: - image: redis:7-alpine - restart: unless-stopped - volumes: - - redis_data:/data - # 仅暴露给内部网络,生产环境不要对外开放 - # ports: - # - "6379:6379" - healthcheck: - test: ["CMD-SHELL", "redis-cli ping | grep PONG"] - interval: 5s - timeout: 5s - retries: 5 - volumes: sqlite_data: - redis_data: diff --git a/package-lock.json b/package-lock.json index 32aeb10..a134005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "ioredis": "^5.10.1", "lucide-react": "^1.8.0", "maxmind": "^5.0.6", "next": "16.2.4", @@ -2053,12 +2052,6 @@ } } }, - "node_modules/@ioredis/commands": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", - "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", - "license": "MIT" - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5043,15 +5036,6 @@ "node": ">=6" } }, - "node_modules/cluster-key-slot": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", - "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -5638,6 +5622,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -7686,30 +7671,6 @@ "node": ">=12" } }, - "node_modules/ioredis": { - "version": "5.10.1", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", - "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", - "license": "MIT", - "dependencies": { - "@ioredis/commands": "1.5.1", - "cluster-key-slot": "^1.1.0", - "debug": "^4.3.4", - "denque": "^2.1.0", - "lodash.defaults": "^4.2.0", - "lodash.isarguments": "^3.1.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0", - "standard-as-callback": "^2.1.0" - }, - "engines": { - "node": ">=12.22.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ioredis" - } - }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -8839,18 +8800,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "license": "MIT" - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -11267,27 +11216,6 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/redis-errors": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/redis-parser": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", - "license": "MIT", - "dependencies": { - "redis-errors": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -12134,12 +12062,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standard-as-callback": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", - "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 4d6dfe9..1939e8f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "ioredis": "^5.10.1", "lucide-react": "^1.8.0", "maxmind": "^5.0.6", "next": "16.2.4", diff --git a/scripts/install-jboard-panel.sh b/scripts/install-jboard-panel.sh index 4791362..c661056 100755 --- a/scripts/install-jboard-panel.sh +++ b/scripts/install-jboard-panel.sh @@ -14,7 +14,6 @@ SITE_NAME="" ADMIN_EMAIL="" ADMIN_PASSWORD="" ADMIN_NAME="" -POSTGRES_PASSWORD="" NEXTAUTH_SECRET="" ENCRYPTION_KEY="" ENV_REUSED="0" @@ -285,17 +284,14 @@ write_env() { printf '# J-Board panel\n' printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")" printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")" - printf '\n# PostgreSQL for local tools; Docker Compose overrides host to db\n' - printf 'POSTGRES_PASSWORD="%s"\n' "$(env_escape "$POSTGRES_PASSWORD")" - printf 'DATABASE_URL="postgresql://jboard:%s@localhost:5432/jboard"\n' "$(env_escape "$POSTGRES_PASSWORD")" + printf '\n# SQLite for local tools and Docker\n' + printf 'DATABASE_URL="file:./storage/jboard.db"\n' printf '\n# NextAuth\n' printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")" printf 'NEXTAUTH_URL="%s"\n' "$(env_escape "$PUBLIC_URL")" printf 'SUBSCRIPTION_URL="%s"\n' "$(env_escape "$SUBSCRIPTION_PUBLIC_URL")" printf '\n# Must be at least 32 bytes, used for AES-256-GCM encryption\n' printf 'ENCRYPTION_KEY="%s"\n' "$(env_escape "$ENCRYPTION_KEY")" - printf '\n# Redis connection URL for local tools; Docker Compose overrides host to redis\n' - printf 'REDIS_URL="redis://localhost:6379"\n' printf '\n# Initial admin account, used by npm run db:seed on first install\n' printf 'ADMIN_EMAIL="%s"\n' "$(env_escape "$ADMIN_EMAIL")" printf 'ADMIN_PASSWORD="%s"\n' "$(env_escape "$ADMIN_PASSWORD")" @@ -341,7 +337,6 @@ configure_env() { ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")" ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")" ADMIN_NAME="$(prompt_value "管理员昵称" "Admin")" - POSTGRES_PASSWORD="$(prompt_generated "PostgreSQL 密码" "$(random_password)")" NEXTAUTH_SECRET="$(prompt_generated "NEXTAUTH_SECRET" "$(random_hex 32)")" ENCRYPTION_KEY="$(prompt_generated "ENCRYPTION_KEY" "$(random_hex 32)" "生产使用后不要更换 ENCRYPTION_KEY,否则已加密的面板密码、Token、凭据会无法解密。")" diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 7bf41b8..e140b7b 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -1,4 +1,3 @@ -import { getRedis } from "./redis"; import { randomUUID } from "crypto"; interface RateLimitResult { @@ -7,12 +6,13 @@ interface RateLimitResult { reset: number; } -/** - * Sliding window rate limiter using Redis. - * @param key - Unique identifier (e.g. `ratelimit:payment:${userId}`) - * @param limit - Max requests allowed in the window - * @param windowSeconds - Time window in seconds - */ +const globalForRateLimit = globalThis as unknown as { + rateLimitBuckets?: Map>; +}; + +const buckets = globalForRateLimit.rateLimitBuckets ?? new Map>(); +globalForRateLimit.rateLimitBuckets = buckets; + export async function rateLimit( key: string, limit: number, @@ -20,33 +20,22 @@ export async function rateLimit( ): Promise { const now = Date.now(); const windowMs = windowSeconds * 1000; + const cutoff = now - windowMs; - try { - const redis = getRedis(); - if (redis.status === "wait") { - await redis.connect(); - } - - const pipeline = redis.pipeline(); - pipeline.zremrangebyscore(key, 0, now - windowMs); - pipeline.zadd(key, now, `${now}:${randomUUID()}`); - pipeline.zcard(key); - pipeline.expire(key, windowSeconds); - - const results = await pipeline.exec(); - const count = (results?.[2]?.[1] as number) ?? 0; - - return { - success: count <= limit, - remaining: Math.max(0, limit - count), - reset: Math.ceil(windowSeconds - (now % (windowSeconds * 1000)) / 1000), - }; - } catch { - // If Redis is unavailable, degrade gracefully instead of blocking user actions. - return { - success: true, - remaining: limit, - reset: windowSeconds, - }; + for (const [bucketKey, items] of buckets) { + const active = items.filter((item) => item.score > cutoff); + if (active.length > 0) buckets.set(bucketKey, active); + else buckets.delete(bucketKey); } + + const bucket = buckets.get(key) ?? []; + bucket.push({ score: now, id: `${now}:${randomUUID()}` }); + buckets.set(key, bucket); + + const count = bucket.length; + return { + success: count <= limit, + remaining: Math.max(0, limit - count), + reset: Math.ceil(windowSeconds - (now % windowMs) / 1000), + }; } diff --git a/src/lib/redis.ts b/src/lib/redis.ts deleted file mode 100644 index 035158d..0000000 --- a/src/lib/redis.ts +++ /dev/null @@ -1,36 +0,0 @@ -import Redis from "ioredis"; - -const globalForRedis = globalThis as unknown as { - redis?: Redis; - redisErrorBound?: boolean; -}; - -function createRedis() { - const client = new Redis(process.env.REDIS_URL || "redis://localhost:6379", { - lazyConnect: true, - maxRetriesPerRequest: 1, - enableOfflineQueue: false, - }); - - if (!globalForRedis.redisErrorBound) { - client.on("error", (error) => { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[redis] ${message}`); - }); - globalForRedis.redisErrorBound = true; - } - - return client; -} - -export function getRedis() { - if (!globalForRedis.redis) { - globalForRedis.redis = createRedis(); - } - - return globalForRedis.redis; -} - -if (process.env.NODE_ENV !== "production") { - globalForRedis.redis = globalForRedis.redis ?? createRedis(); -}