This commit is contained in:
Vaica committed 2026-06-22 14:59:24 +08:00
1 parent 4ab47a3e47
commit ba016efbe8
21 files changed
+921 -72

No files matched your search

+2 -1
View File
@@ -10,4 +10,5 @@ node_modules
!package-lock.json !package-lock.json
/nginx /nginx
/systemd /systemd
/scripts /scripts/*
!/scripts/hash-users.js
+3
View File
@@ -65,6 +65,9 @@ COPY --from=builder /app/package.json ./
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
# Copy password hash script
COPY scripts/hash-users.js /app/scripts/hash-users.js
# Create necessary directories # Create necessary directories
RUN mkdir -p /app/logs /app/backups /app/recycle/posts /app/recycle/meta RUN mkdir -p /app/logs /app/backups /app/recycle/posts /app/recycle/meta
+2
View File
@@ -23,6 +23,8 @@ services:
- ./backups:/app/backups - ./backups:/app/backups
# 环境变量文件 # 环境变量文件
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
# 用户配置
- ./users.json:/app/users.json:ro
# SSH 密钥(用于 git push) # SSH 密钥(用于 git push)
- /root/.ssh:/home/nextjs/.ssh - /root/.ssh:/home/nextjs/.ssh
environment: environment:
+11 -4
View File
@@ -1,6 +1,5 @@
#!/bin/sh #!/bin/sh
# Docker entrypoint script # Docker entrypoint script
# 修复挂载目录权限后以 nextjs 用户启动应用
# 创建必要的子目录(以 root 身份) # 创建必要的子目录(以 root 身份)
mkdir -p /app/recycle/posts /app/recycle/meta /app/logs /app/backups 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 目录权限 # 修复 recycle 目录权限
chown -R nextjs:nodejs /app/recycle /app/logs /app/backups 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 if [ -d "/blog" ]; then
# 博客根目录及其所有文件
chown -R nextjs:nodejs /blog chown -R nextjs:nodejs /blog
fi fi
# 配置 git(容器内 nextjs 用户) # 配置 git
su-exec nextjs git config --global user.name "Write Server" 2>/dev/null 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 user.email "write@usj.cc" 2>/dev/null
su-exec nextjs git config --global --add safe.directory /blog 2>/dev/null su-exec nextjs git config --global --add safe.directory /blog 2>/dev/null
+2 -32
View File
@@ -29,29 +29,15 @@ server {
access_log /var/log/nginx/write-server-access.log; access_log /var/log/nginx/write-server-access.log;
error_log /var/log/nginx/write-server-error.log; error_log /var/log/nginx/write-server-error.log;
# 静态文件(登录页、Service Worker、htpasswd 验证) # 静态文件(Service Worker)
location /sw.js { location /sw.js {
alias /etc/nginx/sw.js; alias /etc/nginx/sw.js;
add_header Content-Type application/javascript; add_header Content-Type application/javascript;
add_header Cache-Control "no-cache, no-store"; add_header Cache-Control "no-cache, no-store";
} }
location /login.html { # 所有请求代理到 write-server
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 验证
location / { location / {
auth_request /auth/internal;
proxy_pass http://write-server:8016; proxy_pass http://write-server:8016;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
@@ -66,22 +52,6 @@ server {
proxy_send_timeout 60s; proxy_send_timeout 60s;
proxy_read_timeout 120s; proxy_read_timeout 120s;
client_max_body_size 50m; 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 ~ /\. { location ~ /\. {
+12 -11
View File
@@ -1,14 +1,18 @@
const CACHE = 'v1'; const CACHE = 'v1';
const LOGIN = '/login.html'; const LOGIN = '/login';
const EXPIRE_MS = 3 * 60 * 60 * 1000; // 3小时 const EXPIRE_MS = 3 * 60 * 60 * 1000;
self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.clients.claim())); self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
self.addEventListener('fetch', e => { self.addEventListener('fetch', e => {
const req = e.request; 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)); e.respondWith(fetch(req));
return; return;
} }
@@ -18,16 +22,13 @@ self.addEventListener('fetch', e => {
const tokenTime = self.__auth_time || 0; const tokenTime = self.__auth_time || 0;
if (!token || (Date.now() - tokenTime > EXPIRE_MS)) { if (!token || (Date.now() - tokenTime > EXPIRE_MS)) {
// token 过期或不存在,重定向到登录页 self.__auth_token = null;
if (token) { self.__auth_time = 0;
self.__auth_token = null;
self.__auth_time = 0;
}
return Response.redirect(LOGIN, 302); return Response.redirect(LOGIN, 302);
} }
const headers = new Headers(req.headers); 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 })); const res = await fetch(new Request(req, { headers }));
if (res.status === 401) { if (res.status === 401) {
@@ -35,11 +36,11 @@ self.addEventListener('fetch', e => {
self.__auth_time = 0; self.__auth_time = 0;
return Response.redirect(LOGIN, 302); return Response.redirect(LOGIN, 302);
} }
return res; return res;
})()); })());
}); });
// 接收页面发来的 token
self.addEventListener('message', e => { self.addEventListener('message', e => {
if (e.data && e.data.type === 'SET_TOKEN') { if (e.data && e.data.type === 'SET_TOKEN') {
self.__auth_token = e.data.token; self.__auth_token = e.data.token;
+35
View File
@@ -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(", "));
@@ -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 } });
}
+13 -2
View File
@@ -1,18 +1,23 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { listPosts, createPost, getPostCount } from "@/lib/posts"; import { listPosts, createPost, getPostCount } from "@/lib/posts";
import { cleanExpired } from "@/lib/recycle"; import { cleanExpired } from "@/lib/recycle";
import { getAuthFromRequest } from "@/lib/auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
cleanExpired(); // housekeeping on each request cleanExpired();
const user = getAuthFromRequest(request);
const { searchParams } = request.nextUrl; const { searchParams } = request.nextUrl;
const search = searchParams.get("search") || ""; const search = searchParams.get("search") || "";
const page = parseInt(searchParams.get("page") || "1", 10); const page = parseInt(searchParams.get("page") || "1", 10);
const perPage = parseInt(searchParams.get("perPage") || "20", 10); const perPage = parseInt(searchParams.get("perPage") || "20", 10);
const includeDrafts = searchParams.get("includeDrafts") !== "false"; 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(); const totalCount = getPostCount();
return NextResponse.json({ ...result, totalCount }); return NextResponse.json({ ...result, totalCount });
@@ -26,6 +31,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const user = getAuthFromRequest(request);
const body = await request.json(); const body = await request.json();
const { frontMatter, content } = body; 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 || ""); const dirPath = createPost(frontMatter, content || "");
return NextResponse.json({ success: true, dirPath }, { status: 201 }); return NextResponse.json({ success: true, dirPath }, { status: 201 });
} catch (error: unknown) { } catch (error: unknown) {
+161
View File
@@ -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: "用户已删除" });
}
+2 -5
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Sidebar from "@/components/Sidebar"; import AppShell from "@/components/AppShell";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "写作后台", title: "写作后台",
@@ -26,10 +26,7 @@ export default function RootLayout({
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head> </head>
<body className="h-full md:flex bg-bg text-text"> <body className="h-full md:flex bg-bg text-text">
<Sidebar /> <AppShell>{children}</AppShell>
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
{children}
</main>
</body> </body>
</html> </html>
); );
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
// 已登录则跳转
const token = localStorage.getItem("wauth_token");
if (token) {
fetch("/api/auth/login", {
headers: { "X-Auth-User": token },
}).then(r => {
if (r.ok) router.replace("/");
else localStorage.removeItem("wauth_token");
}).catch(() => localStorage.removeItem("wauth_token"));
}
}, [router]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "登录失败");
return;
}
// 存储用户信息
localStorage.setItem("wauth_token", data.user.username);
localStorage.setItem("wauth_user", JSON.stringify(data.user));
// 注册 Service Worker
if ("serviceWorker" in navigator) {
const reg = await navigator.serviceWorker.register("/sw.js");
if (reg.installing) {
await new Promise<void>(resolve => {
reg.installing!.addEventListener("statechange", function () {
if (this.state === "activated") resolve();
});
});
}
const sw = reg.active || reg.waiting || reg.installing;
if (sw) {
sw.postMessage({ type: "SET_TOKEN", token: data.user.username });
}
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ type: "SET_TOKEN", token: data.user.username });
}
}
router.replace("/");
} catch {
setError("网络错误,请重试");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-bg">
<div className="bg-surface rounded-2xl shadow-lg p-10 w-full max-w-sm">
<h1 className="text-xl font-bold text-accent mb-1">写作后台</h1>
<p className="text-[13px] text-muted mb-8">优世界 · Blog Admin</p>
{error && (
<div className="bg-red-50 text-red-600 text-[13px] px-4 py-2.5 rounded-lg mb-5">
{error}
</div>
)}
<form onSubmit={handleLogin} className="space-y-5">
<div>
<label className="block text-[13px] text-text font-medium mb-1.5">用户名</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-lg border border-line bg-bg text-text text-sm outline-none focus:border-accent transition-colors"
autoFocus
required
/>
</div>
<div>
<label className="block text-[13px] text-text font-medium mb-1.5">密码</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full px-3.5 py-2.5 rounded-lg border border-line bg-bg text-text text-sm outline-none focus:border-accent transition-colors"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-accent text-white rounded-lg text-sm font-semibold hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{loading ? "登录中..." : "登 录"}
</button>
</form>
</div>
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Plus, Trash2, Save, Key, User } from "lucide-react";
interface UserInfo {
username: string;
name: string;
role: string;
}
export default function UsersPage() {
const router = useRouter();
const [users, setUsers] = useState<UserInfo[]>([]);
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState<{ type: "ok" | "err"; text: string } | null>(null);
// 修改密码
const [showPasswordForm, setShowPasswordForm] = useState(false);
const [passwordTarget, setPasswordTarget] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
// 添加用户
const [showAddForm, setShowAddForm] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [addPassword, setAddPassword] = useState("");
const [newName, setNewName] = useState("");
const [newRole, setNewRole] = useState("user");
const isAdmin = currentUser?.role === "admin";
useEffect(() => {
try {
const u = JSON.parse(localStorage.getItem("wauth_user") || "{}");
setCurrentUser(u);
} catch {}
fetchUsers();
}, []);
async function fetchUsers() {
setLoading(true);
try {
const token = localStorage.getItem("wauth_token");
const res = await fetch("/api/users", {
headers: { "X-Auth-User": token || "" },
});
const data = await res.json();
setUsers(data.users || []);
} catch {} finally { setLoading(false); }
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
try {
const token = localStorage.getItem("wauth_token");
const res = await fetch("/api/users", {
method: "PUT",
headers: { "Content-Type": "application/json", "X-Auth-User": token || "" },
body: JSON.stringify({
targetUsername: passwordTarget || currentUser?.username,
oldPassword,
newPassword,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setMessage({ type: "ok", text: "密码修改成功" });
setShowPasswordForm(false);
setOldPassword("");
setNewPassword("");
} catch (e) {
setMessage({ type: "err", text: e instanceof Error ? e.message : "修改失败" });
}
}
async function handleAddUser(e: React.FormEvent) {
e.preventDefault();
setMessage(null);
try {
const token = localStorage.getItem("wauth_token");
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json", "X-Auth-User": token || "" },
body: JSON.stringify({ username: newUsername, password: addPassword, name: newName, role: newRole }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setMessage({ type: "ok", text: "用户添加成功" });
setShowAddForm(false);
setNewUsername("");
setAddPassword("");
setNewName("");
fetchUsers();
} catch (e) {
setMessage({ type: "err", text: e instanceof Error ? e.message : "添加失败" });
}
}
async function handleDeleteUser(username: string) {
if (!confirm(`确定删除用户 ${username}?`)) return;
setMessage(null);
try {
const token = localStorage.getItem("wauth_token");
const res = await fetch(`/api/users?username=${username}`, {
method: "DELETE",
headers: { "X-Auth-User": token || "" },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setMessage({ type: "ok", text: "用户已删除" });
fetchUsers();
} catch (e) {
setMessage({ type: "err", text: e instanceof Error ? e.message : "删除失败" });
}
}
return (
<div className="h-full flex flex-col px-4 py-4 md:px-8 md:py-6">
<div className="flex items-center gap-3 mb-6 shrink-0">
<button type="button" onClick={() => router.back()} className="p-1 -ml-1 rounded hover:bg-surface text-muted hover:text-text transition-colors">
<ArrowLeft size={16} />
</button>
<h2 className="text-lg font-bold text-text">用户管理</h2>
</div>
{message && (
<div className={`mb-4 px-4 py-2.5 rounded-lg text-[13px] ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-600"}`}>
{message.text}
</div>
)}
{/* 用户列表 */}
<div className="rounded-lg border border-line overflow-hidden mb-6">
<table className="w-full text-[13px]">
<thead className="bg-surface">
<tr>
<th className="text-left px-4 py-2.5 font-medium text-muted">用户名</th>
<th className="text-left px-4 py-2.5 font-medium text-muted">昵称</th>
<th className="text-left px-4 py-2.5 font-medium text-muted">角色</th>
<th className="text-right px-4 py-2.5 font-medium text-muted">操作</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.username} className="border-t border-line">
<td className="px-4 py-2.5">{u.username}</td>
<td className="px-4 py-2.5">{u.name}</td>
<td className="px-4 py-2.5">
<span className={`px-2 py-0.5 rounded text-[11px] ${u.role === "admin" ? "bg-accent/10 text-accent" : "bg-surface text-muted"}`}>
{u.role === "admin" ? "管理员" : "用户"}
</span>
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => { setPasswordTarget(u.username); setShowPasswordForm(true); setMessage(null); }}
className="p-1.5 rounded hover:bg-surface text-muted hover:text-accent transition-colors"
title="修改密码"
>
<Key size={14} />
</button>
{isAdmin && u.username !== currentUser?.username && (
<button
onClick={() => handleDeleteUser(u.username)}
className="p-1.5 rounded hover:bg-danger/8 text-muted hover:text-danger transition-colors ml-1"
title="删除用户"
>
<Trash2 size={14} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 添加用户按钮(仅 admin) */}
{isAdmin && (
<button
onClick={() => { setShowAddForm(true); setMessage(null); }}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-accent hover:border-accent/30 transition-colors mb-6"
>
<Plus size={14} /> 添加用户
</button>
)}
{/* 修改密码表单 */}
{showPasswordForm && (
<div className="rounded-lg border border-line p-5 mb-6">
<h3 className="font-medium text-sm mb-4">修改密码 — {passwordTarget}</h3>
<form onSubmit={handleChangePassword} className="space-y-4">
{(!isAdmin || passwordTarget === currentUser?.username) && (
<div>
<label className="block text-[13px] text-muted mb-1">旧密码</label>
<input type="password" value={oldPassword} onChange={e => setOldPassword(e.target.value)}
className="w-full max-w-xs px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
</div>
)}
<div>
<label className="block text-[13px] text-muted mb-1">新密码</label>
<input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full max-w-xs px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
</div>
<div className="flex gap-2">
<button type="submit" className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-90">
<Save size={13} /> 保存
</button>
<button type="button" onClick={() => setShowPasswordForm(false)} className="px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-text transition-colors">
取消
</button>
</div>
</form>
</div>
)}
{/* 添加用户表单 */}
{showAddForm && (
<div className="rounded-lg border border-line p-5">
<h3 className="font-medium text-sm mb-4">添加用户</h3>
<form onSubmit={handleAddUser} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-[13px] text-muted mb-1">用户名</label>
<input type="text" value={newUsername} onChange={e => setNewUsername(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
</div>
<div>
<label className="block text-[13px] text-muted mb-1">昵称</label>
<input type="text" value={newName} onChange={e => setNewName(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
</div>
<div>
<label className="block text-[13px] text-muted mb-1">密码</label>
<input type="password" value={addPassword} onChange={e => setAddPassword(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent" required />
</div>
<div>
<label className="block text-[13px] text-muted mb-1">角色</label>
<select value={newRole} onChange={e => setNewRole(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-line bg-bg text-sm outline-none focus:border-accent">
<option value="user">用户</option>
<option value="admin">管理员</option>
</select>
</div>
</div>
<div className="flex gap-2">
<button type="submit" className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-90">
<User size={13} /> 添加
</button>
<button type="button" onClick={() => setShowAddForm(false)} className="px-4 py-2 rounded-lg border border-line text-[13px] text-muted hover:text-text transition-colors">
取消
</button>
</div>
</form>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { usePathname } from "next/navigation";
import Sidebar from "@/components/Sidebar";
import AuthGuard from "@/components/AuthGuard";
export default function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === "/login";
if (isLoginPage) {
return <AuthGuard>{children}</AuthGuard>;
}
return (
<AuthGuard>
<Sidebar />
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
{children}
</main>
</AuthGuard>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [checked, setChecked] = useState(false);
useEffect(() => {
// 登录页不需要检查
if (pathname === "/login") {
setChecked(true);
return;
}
const token = localStorage.getItem("wauth_token");
if (!token) {
router.replace("/login");
return;
}
// 验证 token 是否有效
fetch("/api/auth/login", {
headers: { "X-Auth-User": token },
})
.then(r => {
if (!r.ok) {
localStorage.removeItem("wauth_token");
localStorage.removeItem("wauth_user");
router.replace("/login");
} else {
setChecked(true);
// 注册 Service Worker
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").then(reg => {
const sw = reg.active || reg.waiting || reg.installing;
if (sw) sw.postMessage({ type: "SET_TOKEN", token });
});
}
}
})
.catch(() => {
setChecked(true); // 网络错误时也显示页面
});
}, [pathname, router]);
if (!checked) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-muted text-sm">加载中...</div>
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,24 @@
"use client";
import { usePathname } from "next/navigation";
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isLoginPage = pathname === "/login";
if (isLoginPage) {
return <>{children}</>;
}
// 动态导入 Sidebar 避免在登录页加载
const Sidebar = require("@/components/Sidebar").default;
return (
<>
<Sidebar />
<main className="flex-1 overflow-auto pt-12 md:pt-0 h-full">
{children}
</main>
</>
);
}
+54 -12
View File
@@ -3,26 +3,36 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot } from "lucide-react"; import { FileText, PenSquare, MessageSquare, Trash2, Rss, Link2, MessageCircle, Image, Menu, X, Bot, LogOut, Users } from "lucide-react";
import ThemeToggle from "./ThemeToggle"; import ThemeToggle from "./ThemeToggle";
const navItems = [ const navItems = [
{ href: "/", label: "文章", icon: FileText }, { href: "/", label: "文章", icon: FileText, roles: ["admin", "user"] },
{ href: "/edit", label: "撰写", icon: PenSquare }, { href: "/edit", label: "撰写", icon: PenSquare, roles: ["admin", "user"] },
{ href: "/comments", label: "评论", icon: MessageSquare }, { href: "/comments", label: "评论", icon: MessageSquare, roles: ["admin", "user"] },
{ href: "/feeds", label: "订阅源", icon: Rss }, { href: "/feeds", label: "订阅源", icon: Rss, roles: ["admin"] },
{ href: "/links", label: "友链", icon: Link2 }, { href: "/links", label: "友链", icon: Link2, roles: ["admin"] },
{ href: "/images", label: "图片", icon: Image }, { href: "/images", label: "图片", icon: Image, roles: ["admin", "user"] },
{ href: "/wechat", label: "公众号", icon: MessageCircle }, { href: "/wechat", label: "公众号", icon: MessageCircle, roles: ["admin"] },
{ href: "/recycle", label: "回收站", icon: Trash2 }, { href: "/recycle", label: "回收站", icon: Trash2, roles: ["admin", "user"] },
{ href: "/bot", label: "Bot", icon: Bot }, { href: "/bot", label: "Bot", icon: Bot, roles: ["admin"] },
]; ];
export default function Sidebar() { export default function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [userRole, setUserRole] = useState("user");
useEffect(() => { setOpen(false); }, [pathname]); useEffect(() => { setOpen(false); }, [pathname]);
useEffect(() => {
try {
const u = JSON.parse(localStorage.getItem("wauth_user") || "{}");
setIsAdmin(u.role === "admin");
setUserRole(u.role || "user");
} catch {}
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -63,7 +73,7 @@ export default function Sidebar() {
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-2.5 py-3 space-y-0.5"> <nav className="flex-1 px-2.5 py-3 space-y-0.5">
{navItems.map((item) => { {navItems.filter(item => item.roles.includes(userRole)).map((item) => {
const active = isActive(item.href); const active = isActive(item.href);
return ( return (
<Link <Link
@@ -80,12 +90,44 @@ export default function Sidebar() {
</Link> </Link>
); );
})} })}
{isAdmin && (
<Link
href="/users"
className={`flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] transition-colors ${
isActive("/users")
? "bg-accent text-white font-medium"
: "text-text/80 hover:bg-bg hover:text-accent"
}`}
>
<Users size={15} strokeWidth={isActive("/users") ? 2.5 : 1.8} />
用户管理
</Link>
)}
</nav> </nav>
{/* Footer */} {/* Footer */}
<div className="px-4 py-3 border-t border-line"> <div className="px-4 py-3 border-t border-line">
<ThemeToggle /> <ThemeToggle />
<p className="text-[10px] text-muted/50">localhost:8016</p> <div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-muted/70">
{(() => { try { return JSON.parse(localStorage.getItem("wauth_user") || "{}").name || "未登录"; } catch { return "未登录"; } })()}
</p>
<button
type="button"
onClick={() => {
localStorage.removeItem("wauth_token");
localStorage.removeItem("wauth_user");
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({ type: "CLEAR_TOKEN" });
}
location.href = "/login";
}}
className="p-1 rounded hover:bg-bg text-muted hover:text-text transition-colors"
title="退出登录"
>
<LogOut size={13} />
</button>
</div>
</div> </div>
</aside> </aside>
</> </>
+71
View File
@@ -0,0 +1,71 @@
import fs from "fs";
import path from "path";
import { createHash, randomBytes } from "crypto";
interface StoredUser {
username: string;
passwordHash: string;
salt: string;
name: string;
role: "admin" | "user";
}
interface User {
username: string;
name: string;
role: "admin" | "user";
}
// 密码哈希
export function hashPassword(password: string, salt: string): string {
return createHash("sha256").update(salt + password).digest("hex");
}
// 生成密码哈希(用于创建用户)
export function generatePasswordHash(password: string): { hash: string; salt: string } {
const salt = randomBytes(16).toString("hex");
const hash = hashPassword(password, salt);
return { hash, salt };
}
// 从请求头获取用户信息
export function getAuthFromRequest(request: Request): User | null {
const authHeader = request.headers.get("X-Auth-User");
if (!authHeader) return null;
const stored = findStoredUser(authHeader);
if (!stored) return null;
return { username: stored.username, name: stored.name, role: stored.role };
}
// 查找用户
function findStoredUser(username: string): StoredUser | null {
const users = loadStoredUsers();
return users.find(u => u.username === username) || null;
}
// 认证用户
export function authenticateUser(username: string, password: string): User | null {
const stored = findStoredUser(username);
if (!stored) return null;
const hash = hashPassword(password, stored.salt);
if (hash !== stored.passwordHash) return null;
return { username: stored.username, name: stored.name, role: stored.role };
}
// 加载用户列表
function loadStoredUsers(): StoredUser[] {
try {
const usersPath = path.join(process.cwd(), "users.json");
if (!fs.existsSync(usersPath)) return [];
const data = JSON.parse(fs.readFileSync(usersPath, "utf-8"));
return data.users || [];
} catch {
return [];
}
}
// 是否是管理员
export function isAdmin(request: Request): boolean {
const user = getAuthFromRequest(request);
return user?.role === "admin";
}
+5 -1
View File
@@ -76,8 +76,9 @@ export function listPosts(options?: {
page?: number; page?: number;
perPage?: number; perPage?: number;
includeDrafts?: boolean; includeDrafts?: boolean;
author?: string;
}): { posts: PostListItem[]; total: number; totalPages: number } { }): { posts: PostListItem[]; total: number; totalPages: number } {
const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true } = options || {}; const { search = "", page = 1, perPage = ITEMS_PER_PAGE, includeDrafts = true, author } = options || {};
const dirs = findPostDirectories(); const dirs = findPostDirectories();
const allPosts: PostListItem[] = []; const allPosts: PostListItem[] = [];
@@ -102,6 +103,9 @@ export function listPosts(options?: {
if (!includeDrafts && post.draft) continue; if (!includeDrafts && post.draft) continue;
// 按作者过滤
if (author && post.author !== author) continue;
if (search) { if (search) {
const q = search.toLowerCase(); const q = search.toLowerCase();
const match = const match =
+2 -4
View File
@@ -1,8 +1,6 @@
import { DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL } from "@/lib/config";
// 语音转文字:使用 OpenAI Whisper API 或其他兼容服务 // 语音转文字:使用 OpenAI Whisper API 或其他兼容服务
const WHISPER_API_KEY = process.env.WHISPER_API_KEY || DEEPSEEK_API_KEY || ""; const WHISPER_API_KEY = process.env.WHISPER_API_KEY || process.env.DEEPSEEK_API_KEY || "";
const WHISPER_API_BASE = process.env.WHISPER_API_BASE || (DEEPSEEK_API_KEY ? DEEPSEEK_BASE_URL : "https://api.openai.com/v1"); const WHISPER_API_BASE = process.env.WHISPER_API_BASE || process.env.DEEPSEEK_BASE_URL || "https://api.openai.com/v1";
const WHISPER_MODEL = process.env.WHISPER_MODEL || "whisper-1"; const WHISPER_MODEL = process.env.WHISPER_MODEL || "whisper-1";
export async function transcribeAudio(audioBuffer: ArrayBuffer, filename: string): Promise<string> { export async function transcribeAudio(audioBuffer: ArrayBuffer, filename: string): Promise<string> {
+16
View File
@@ -0,0 +1,16 @@
{
"users": [
{
"username": "admin",
"password": "admin123",
"name": "小赵",
"role": "admin"
},
{
"username": "girlfriend",
"password": "girlfriend123",
"name": "Girlfriend",
"role": "user"
}
]
}