From b1c6878fbafdd8fc47bbb1568e8308b903c685b9 Mon Sep 17 00:00:00 2001 From: Vaica <94612053+zqlit@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:59:24 +0800 Subject: [PATCH] 1459 --- write-server/.dockerignore | 3 +- write-server/Dockerfile | 3 + write-server/docker-compose.yml | 2 + write-server/entrypoint.sh | 15 +- write-server/nginx/conf.d/write-server.conf | 34 +-- write-server/nginx/sw.js | 23 +- write-server/scripts/hash-users.js | 35 +++ write-server/src/app/api/auth/login/route.ts | 41 +++ write-server/src/app/api/posts/route.ts | 15 +- write-server/src/app/api/users/route.ts | 161 +++++++++++ write-server/src/app/layout.tsx | 7 +- write-server/src/app/login/page.tsx | 121 ++++++++ write-server/src/app/users/page.tsx | 263 ++++++++++++++++++ write-server/src/components/AppShell.tsx | 23 ++ write-server/src/components/AuthGuard.tsx | 58 ++++ write-server/src/components/LayoutWrapper.tsx | 24 ++ write-server/src/components/Sidebar.tsx | 66 ++++- write-server/src/lib/auth.ts | 71 +++++ write-server/src/lib/posts.ts | 6 +- write-server/src/lib/voice.ts | 6 +- write-server/users.json | 16 ++ 21 files changed, 921 insertions(+), 72 deletions(-) create mode 100644 write-server/scripts/hash-users.js create mode 100644 write-server/src/app/api/auth/login/route.ts create mode 100644 write-server/src/app/api/users/route.ts create mode 100644 write-server/src/app/login/page.tsx create mode 100644 write-server/src/app/users/page.tsx create mode 100644 write-server/src/components/AppShell.tsx create mode 100644 write-server/src/components/AuthGuard.tsx create mode 100644 write-server/src/components/LayoutWrapper.tsx create mode 100644 write-server/src/lib/auth.ts create mode 100644 write-server/users.json diff --git a/write-server/.dockerignore b/write-server/.dockerignore index 80c611d4..e5825fed 100644 --- a/write-server/.dockerignore +++ b/write-server/.dockerignore @@ -10,4 +10,5 @@ node_modules !package-lock.json /nginx /systemd -/scripts +/scripts/* +!/scripts/hash-users.js diff --git a/write-server/Dockerfile b/write-server/Dockerfile index d04457b3..f59f1db8 100644 --- a/write-server/Dockerfile +++ b/write-server/Dockerfile @@ -65,6 +65,9 @@ COPY --from=builder /app/package.json ./ COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh +# Copy password hash script +COPY scripts/hash-users.js /app/scripts/hash-users.js + # Create necessary directories RUN mkdir -p /app/logs /app/backups /app/recycle/posts /app/recycle/meta diff --git a/write-server/docker-compose.yml b/write-server/docker-compose.yml index e2536607..957a1513 100644 --- a/write-server/docker-compose.yml +++ b/write-server/docker-compose.yml @@ -23,6 +23,8 @@ services: - ./backups:/app/backups # 环境变量文件 - ./.env:/app/.env:ro + # 用户配置 + - ./users.json:/app/users.json:ro # SSH 密钥(用于 git push) - /root/.ssh:/home/nextjs/.ssh environment: diff --git a/write-server/entrypoint.sh b/write-server/entrypoint.sh index 33e07af1..543800f6 100644 --- a/write-server/entrypoint.sh +++ b/write-server/entrypoint.sh @@ -1,6 +1,5 @@ #!/bin/sh # Docker entrypoint script -# 修复挂载目录权限后以 nextjs 用户启动应用 # 创建必要的子目录(以 root 身份) mkdir -p /app/recycle/posts /app/recycle/meta /app/logs /app/backups @@ -8,13 +7,21 @@ mkdir -p /app/recycle/posts /app/recycle/meta /app/logs /app/backups # 修复 recycle 目录权限 chown -R nextjs:nodejs /app/recycle /app/logs /app/backups -# 修复博客目录权限(让 nextjs 用户可写) +# 自动哈希明文密码(users.json 中没有 salt 字段时) +if [ -f "/app/users.json" ]; then + if ! grep -q '"salt"' /app/users.json 2>/dev/null; then + echo "[entrypoint] 检测到明文密码,自动哈希..." + node /app/scripts/hash-users.js + echo "[entrypoint] 密码哈希完成" + fi +fi + +# 修复博客目录权限 if [ -d "/blog" ]; then - # 博客根目录及其所有文件 chown -R nextjs:nodejs /blog fi -# 配置 git(容器内 nextjs 用户) +# 配置 git su-exec nextjs git config --global user.name "Write Server" 2>/dev/null su-exec nextjs git config --global user.email "write@usj.cc" 2>/dev/null su-exec nextjs git config --global --add safe.directory /blog 2>/dev/null diff --git a/write-server/nginx/conf.d/write-server.conf b/write-server/nginx/conf.d/write-server.conf index fa499483..80f9a69c 100644 --- a/write-server/nginx/conf.d/write-server.conf +++ b/write-server/nginx/conf.d/write-server.conf @@ -29,29 +29,15 @@ server { access_log /var/log/nginx/write-server-access.log; error_log /var/log/nginx/write-server-error.log; - # 静态文件(登录页、Service Worker、htpasswd 验证) + # 静态文件(Service Worker) location /sw.js { alias /etc/nginx/sw.js; add_header Content-Type application/javascript; add_header Cache-Control "no-cache, no-store"; } - location /login.html { - alias /etc/nginx/login.html; - add_header Cache-Control "no-cache, no-store"; - } - - location /auth/check { - auth_basic "Write Server"; - auth_basic_user_file /etc/nginx/.htpasswd; - return 200 'ok'; - add_header Content-Type text/plain; - } - - # 所有其他请求通过 auth_request 验证 + # 所有请求代理到 write-server location / { - auth_request /auth/internal; - proxy_pass http://write-server:8016; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -66,22 +52,6 @@ server { proxy_send_timeout 60s; proxy_read_timeout 120s; client_max_body_size 50m; - - error_page 401 = @login; - } - - location = /auth/internal { - internal; - proxy_pass http://write-server:8016/api/stats; - proxy_set_header Authorization $http_authorization; - proxy_pass_request_body off; - proxy_set_header Content-Length ""; - } - - location @login { - internal; - add_header WWW-Authenticate ""; - return 302 /login.html; } location ~ /\. { diff --git a/write-server/nginx/sw.js b/write-server/nginx/sw.js index 1d2805f3..ee2d50c5 100644 --- a/write-server/nginx/sw.js +++ b/write-server/nginx/sw.js @@ -1,14 +1,18 @@ const CACHE = 'v1'; -const LOGIN = '/login.html'; -const EXPIRE_MS = 3 * 60 * 60 * 1000; // 3小时 +const LOGIN = '/login'; +const EXPIRE_MS = 3 * 60 * 60 * 1000; self.addEventListener('install', () => self.skipWaiting()); - self.addEventListener('activate', e => e.waitUntil(self.clients.claim())); self.addEventListener('fetch', e => { const req = e.request; - if (req.url.includes('/login.html') || req.url.includes('/auth/')) { + const url = new URL(req.url); + + // 登录页、静态资源不拦截 + if (url.pathname === '/login' || url.pathname.startsWith('/_next/') || + url.pathname.endsWith('.js') || url.pathname.endsWith('.css') || + url.pathname.endsWith('.ico') || url.pathname.endsWith('.woff2')) { e.respondWith(fetch(req)); return; } @@ -18,16 +22,13 @@ self.addEventListener('fetch', e => { const tokenTime = self.__auth_time || 0; if (!token || (Date.now() - tokenTime > EXPIRE_MS)) { - // token 过期或不存在,重定向到登录页 - if (token) { - self.__auth_token = null; - self.__auth_time = 0; - } + self.__auth_token = null; + self.__auth_time = 0; return Response.redirect(LOGIN, 302); } const headers = new Headers(req.headers); - headers.set('Authorization', 'Basic ' + token); + headers.set('X-Auth-User', token); const res = await fetch(new Request(req, { headers })); if (res.status === 401) { @@ -35,11 +36,11 @@ self.addEventListener('fetch', e => { self.__auth_time = 0; return Response.redirect(LOGIN, 302); } + return res; })()); }); -// 接收页面发来的 token self.addEventListener('message', e => { if (e.data && e.data.type === 'SET_TOKEN') { self.__auth_token = e.data.token; diff --git a/write-server/scripts/hash-users.js b/write-server/scripts/hash-users.js new file mode 100644 index 00000000..22a1811a --- /dev/null +++ b/write-server/scripts/hash-users.js @@ -0,0 +1,35 @@ +// 用法: node scripts/hash-users.js +// 生成哈希后的 users.json + +const { createHash, randomBytes } = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +function hashPassword(password, salt) { + return createHash("sha256").update(salt + password).digest("hex"); +} + +// 在这里修改用户信息 +const users = [ + { username: "admin", password: "admin123", name: "小赵", role: "admin" }, + { username: "xiaomei", password: "xiaomei123", name: "小美", role: "user" }, +]; + +const hashedUsers = users.map(u => { + const salt = randomBytes(16).toString("hex"); + const passwordHash = hashPassword(u.password, salt); + return { + username: u.username, + passwordHash, + salt, + name: u.name, + role: u.role, + }; +}); + +const output = { users: hashedUsers }; +const outPath = path.join(__dirname, "..", "users.json"); +fs.writeFileSync(outPath, JSON.stringify(output, null, 2) + "\n"); + +console.log("已生成 users.json(密码已哈希)"); +console.log("用户名列表:", hashedUsers.map(u => u.username).join(", ")); diff --git a/write-server/src/app/api/auth/login/route.ts b/write-server/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..457ce1f6 --- /dev/null +++ b/write-server/src/app/api/auth/login/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authenticateUser, getAuthFromRequest } from "@/lib/auth"; + +// POST /api/auth/login - 登录 +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { username, password } = body; + + if (!username || !password) { + return NextResponse.json({ error: "用户名和密码不能为空" }, { status: 400 }); + } + + const user = authenticateUser(username, password); + if (!user) { + return NextResponse.json({ error: "用户名或密码错误" }, { status: 401 }); + } + + const token = Buffer.from(`${username}:${Date.now()}`).toString("base64"); + + return NextResponse.json({ + ok: true, + token, + user: { username: user.username, name: user.name, role: user.role }, + }); + } catch (error: unknown) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Unknown error" }, + { status: 500 } + ); + } +} + +// GET /api/auth/login - 验证当前用户 +export async function GET(request: NextRequest) { + const user = getAuthFromRequest(request); + if (!user) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + return NextResponse.json({ ok: true, user: { username: user.username, name: user.name, role: user.role } }); +} diff --git a/write-server/src/app/api/posts/route.ts b/write-server/src/app/api/posts/route.ts index 1f776d1b..29eed52c 100644 --- a/write-server/src/app/api/posts/route.ts +++ b/write-server/src/app/api/posts/route.ts @@ -1,18 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import { listPosts, createPost, getPostCount } from "@/lib/posts"; import { cleanExpired } from "@/lib/recycle"; +import { getAuthFromRequest } from "@/lib/auth"; export async function GET(request: NextRequest) { try { - cleanExpired(); // housekeeping on each request + cleanExpired(); + const user = getAuthFromRequest(request); const { searchParams } = request.nextUrl; const search = searchParams.get("search") || ""; const page = parseInt(searchParams.get("page") || "1", 10); const perPage = parseInt(searchParams.get("perPage") || "20", 10); const includeDrafts = searchParams.get("includeDrafts") !== "false"; - const result = listPosts({ search, page, perPage, includeDrafts }); + // 管理员看所有文章,普通用户只看自己的 + const author = user?.role === "admin" ? undefined : user?.name; + + const result = listPosts({ search, page, perPage, includeDrafts, author }); const totalCount = getPostCount(); return NextResponse.json({ ...result, totalCount }); @@ -26,6 +31,7 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { + const user = getAuthFromRequest(request); const body = await request.json(); const { frontMatter, content } = body; @@ -36,6 +42,11 @@ export async function POST(request: NextRequest) { ); } + // 自动设置作者为当前登录用户 + if (user) { + frontMatter.author = user.name; + } + const dirPath = createPost(frontMatter, content || ""); return NextResponse.json({ success: true, dirPath }, { status: 201 }); } catch (error: unknown) { diff --git a/write-server/src/app/api/users/route.ts b/write-server/src/app/api/users/route.ts new file mode 100644 index 00000000..e0535064 --- /dev/null +++ b/write-server/src/app/api/users/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { getAuthFromRequest, hashPassword, generatePasswordHash } from "@/lib/auth"; + +interface StoredUser { + username: string; + passwordHash: string; + salt: string; + name: string; + role: "admin" | "user"; +} + +function getUsersPath(): string { + return path.join(process.cwd(), "users.json"); +} + +function loadUsers(): StoredUser[] { + try { + const data = JSON.parse(fs.readFileSync(getUsersPath(), "utf-8")); + return data.users || []; + } catch { + return []; + } +} + +function saveUsers(users: StoredUser[]): void { + fs.writeFileSync(getUsersPath(), JSON.stringify({ users }, null, 2) + "\n"); +} + +// GET - 获取用户列表(admin 可看所有,普通用户只看自己) +export async function GET(request: NextRequest) { + const currentUser = getAuthFromRequest(request); + if (!currentUser) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const users = loadUsers(); + const safeUsers = users.map(u => ({ + username: u.username, + name: u.name, + role: u.role, + })); + + if (currentUser.role === "admin") { + return NextResponse.json({ users: safeUsers }); + } + + return NextResponse.json({ + users: safeUsers.filter(u => u.username === currentUser.username), + }); +} + +// PUT - 修改密码或用户信息 +export async function PUT(request: NextRequest) { + const currentUser = getAuthFromRequest(request); + if (!currentUser) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const body = await request.json(); + const { targetUsername, oldPassword, newPassword, newName } = body; + + const users = loadUsers(); + const targetUser = users.find(u => u.username === (targetUsername || currentUser.username)); + + if (!targetUser) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + // 普通用户只能改自己 + if (currentUser.role !== "admin" && targetUser.username !== currentUser.username) { + return NextResponse.json({ error: "无权修改其他用户" }, { status: 403 }); + } + + // 修改密码 + if (newPassword) { + // 非 admin 修改自己的密码需要验证旧密码 + if (currentUser.role !== "admin" || targetUser.username === currentUser.username) { + if (!oldPassword) { + return NextResponse.json({ error: "请输入旧密码" }, { status: 400 }); + } + const oldHash = hashPassword(oldPassword, targetUser.salt); + if (oldHash !== targetUser.passwordHash) { + return NextResponse.json({ error: "旧密码错误" }, { status: 400 }); + } + } + const { hash, salt } = generatePasswordHash(newPassword); + targetUser.passwordHash = hash; + targetUser.salt = salt; + } + + // 修改昵称 + if (newName) { + targetUser.name = newName; + } + + saveUsers(users); + return NextResponse.json({ ok: true, message: "修改成功" }); +} + +// POST - 添加用户(仅 admin) +export async function POST(request: NextRequest) { + const currentUser = getAuthFromRequest(request); + if (!currentUser || currentUser.role !== "admin") { + return NextResponse.json({ error: "仅管理员可添加用户" }, { status: 403 }); + } + + const body = await request.json(); + const { username, password, name, role } = body; + + if (!username || !password || !name) { + return NextResponse.json({ error: "用户名、密码、昵称不能为空" }, { status: 400 }); + } + + const users = loadUsers(); + if (users.find(u => u.username === username)) { + return NextResponse.json({ error: "用户名已存在" }, { status: 400 }); + } + + const { hash, salt } = generatePasswordHash(password); + users.push({ + username, + passwordHash: hash, + salt, + name, + role: role || "user", + }); + + saveUsers(users); + return NextResponse.json({ ok: true, message: "用户添加成功" }); +} + +// DELETE - 删除用户(仅 admin) +export async function DELETE(request: NextRequest) { + const currentUser = getAuthFromRequest(request); + if (!currentUser || currentUser.role !== "admin") { + return NextResponse.json({ error: "仅管理员可删除用户" }, { status: 403 }); + } + + const { searchParams } = request.nextUrl; + const targetUsername = searchParams.get("username"); + + if (!targetUsername) { + return NextResponse.json({ error: "缺少用户名" }, { status: 400 }); + } + + if (targetUsername === currentUser.username) { + return NextResponse.json({ error: "不能删除自己" }, { status: 400 }); + } + + const users = loadUsers(); + const filtered = users.filter(u => u.username !== targetUsername); + + if (filtered.length === users.length) { + return NextResponse.json({ error: "用户不存在" }, { status: 404 }); + } + + saveUsers(filtered); + return NextResponse.json({ ok: true, message: "用户已删除" }); +} diff --git a/write-server/src/app/layout.tsx b/write-server/src/app/layout.tsx index d7aff835..4732ec02 100644 --- a/write-server/src/app/layout.tsx +++ b/write-server/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; -import Sidebar from "@/components/Sidebar"; +import AppShell from "@/components/AppShell"; export const metadata: Metadata = { title: "写作后台", @@ -26,10 +26,7 @@ export default function RootLayout({
-