2328
This commit is contained in:
1 parent
834d9d0425
commit
424355da7c
6 files changed
+220
-32
No files matched your search
@@ -55,6 +55,8 @@ services:
|
|||||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||||
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
- ./.htpasswd:/etc/nginx/.htpasswd:ro
|
||||||
|
- ./nginx/login.html:/etc/nginx/login.html:ro
|
||||||
|
- ./nginx/sw.js:/etc/nginx/sw.js:ro
|
||||||
- ./logs/nginx:/var/log/nginx
|
- ./logs/nginx:/var/log/nginx
|
||||||
depends_on:
|
depends_on:
|
||||||
write-server:
|
write-server:
|
||||||
|
|||||||
@@ -5,11 +5,19 @@
|
|||||||
# 创建必要的子目录(以 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
|
||||||
|
|
||||||
# 修复权限
|
# 修复 recycle 目录权限
|
||||||
chown -R nextjs:nodejs /app/recycle /app/logs /app/backups
|
chown -R nextjs:nodejs /app/recycle /app/logs /app/backups
|
||||||
|
|
||||||
# 调试:打印环境变量
|
# 修复博客目录权限(让 nextjs 用户可写)
|
||||||
echo "[entrypoint] BLOG_ROOT=$BLOG_ROOT"
|
if [ -d "/blog/content" ]; then
|
||||||
|
chown -R nextjs:nodejs /blog/content
|
||||||
|
fi
|
||||||
|
if [ -d "/blog/static" ]; then
|
||||||
|
chown -R nextjs:nodejs /blog/static
|
||||||
|
fi
|
||||||
|
if [ -f "/blog/hugo.toml" ]; then
|
||||||
|
chown nextjs:nodejs /blog/hugo.toml
|
||||||
|
fi
|
||||||
|
|
||||||
# 以 nextjs 用户启动应用(保留环境变量)
|
# 以 nextjs 用户启动应用
|
||||||
exec su-exec nextjs env "$@"
|
exec su-exec nextjs "$@"
|
||||||
@@ -1,27 +1,57 @@
|
|||||||
# Write Server Nginx 站点配置
|
# Write Server Nginx 站点配置
|
||||||
# 反向代理配置 + Basic Auth 认证
|
|
||||||
|
|
||||||
|
# HTTP -> HTTPS 重定向
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name _; # 匹配所有域名和 IP
|
server_name post.usj.cc;
|
||||||
|
|
||||||
|
# Let's Encrypt 验证
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 其他请求重定向到 HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS 主站
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name post.usj.cc;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
# 日志
|
|
||||||
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;
|
||||||
|
|
||||||
# Basic Auth 认证(保护管理后台)
|
# 静态文件(登录页、Service Worker、htpasswd 验证)
|
||||||
auth_basic "Write Server Admin";
|
location /sw.js {
|
||||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
alias /etc/nginx/sw.js;
|
||||||
|
add_header Content-Type application/javascript;
|
||||||
# 健康检查(不需要认证)
|
add_header Cache-Control "no-cache, no-store";
|
||||||
location /health {
|
|
||||||
auth_basic off;
|
|
||||||
proxy_pass http://write-server:8016/api/stats;
|
|
||||||
access_log off;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 代理到 Write Server
|
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 验证
|
||||||
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;
|
||||||
@@ -32,14 +62,28 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
|
||||||
# 超时设置
|
|
||||||
proxy_connect_timeout 60s;
|
proxy_connect_timeout 60s;
|
||||||
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 ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>写作后台 - 登录</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{min-height:100vh;display:flex;align-items:center;justify-content:center;background:#fcfaf5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||||
|
.login-card{background:#fff;border-radius:16px;box-shadow:0 4px 24px rgba(34,61,102,.08);padding:48px 40px;width:100%;max-width:380px}
|
||||||
|
h1{font-size:20px;color:#1d3d6c;margin-bottom:4px;font-weight:700}
|
||||||
|
.sub{font-size:13px;color:#999;margin-bottom:32px}
|
||||||
|
.field{margin-bottom:20px}
|
||||||
|
.field label{display:block;font-size:13px;color:#223d66;margin-bottom:6px;font-weight:500}
|
||||||
|
.field input{width:100%;padding:10px 14px;border:1.5px solid #e4dfd2;border-radius:8px;font-size:14px;color:#223d66;background:#fcfaf5;outline:none;transition:border .2s}
|
||||||
|
.field input:focus{border-color:#1d3d6c}
|
||||||
|
.btn{width:100%;padding:12px;background:#1d3d6c;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:opacity .2s;margin-top:4px}
|
||||||
|
.btn:hover{opacity:.85}
|
||||||
|
.btn:active{opacity:.7}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.error{background:#fef2f2;color:#dc2626;font-size:13px;padding:10px 14px;border-radius:8px;margin-bottom:16px;display:none}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>写作后台</h1>
|
||||||
|
<p class="sub">优世界 · Blog Admin</p>
|
||||||
|
<div class="error" id="err">用户名或密码错误</div>
|
||||||
|
<form id="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input type="text" id="user" autocomplete="username" autofocus required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input type="password" id="pass" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn" type="submit" id="btn">登 录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('btn');
|
||||||
|
var err = document.getElementById('err');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '登录中...';
|
||||||
|
err.style.display = 'none';
|
||||||
|
|
||||||
|
var u = document.getElementById('user').value;
|
||||||
|
var p = document.getElementById('pass').value;
|
||||||
|
var tok = btoa(u + ':' + p);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 验证凭据
|
||||||
|
var res = await fetch('/auth/check', {
|
||||||
|
headers: { 'Authorization': 'Basic ' + tok }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('auth failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储凭据
|
||||||
|
localStorage.setItem('wauth', tok);
|
||||||
|
|
||||||
|
// 注册 Service Worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
var reg = await navigator.serviceWorker.register('/sw.js');
|
||||||
|
// 等待 SW 激活
|
||||||
|
if (reg.installing) {
|
||||||
|
await new Promise(function(resolve) {
|
||||||
|
reg.installing.addEventListener('statechange', function() {
|
||||||
|
if (this.state === 'activated') resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 发送 token 给 SW
|
||||||
|
var sw = reg.active || reg.waiting || reg.installing;
|
||||||
|
if (sw) {
|
||||||
|
sw.postMessage({ type: 'SET_TOKEN', token: tok });
|
||||||
|
}
|
||||||
|
// 确保 SW 控制当前页面
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
navigator.serviceWorker.controller.postMessage({ type: 'SET_TOKEN', token: tok });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到主页
|
||||||
|
location.href = '/';
|
||||||
|
} catch (err2) {
|
||||||
|
err.style.display = 'block';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '登 录';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已登录则直接跳转
|
||||||
|
if (localStorage.getItem('wauth')) {
|
||||||
|
location.href = '/';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const CACHE = 'v1';
|
||||||
|
const LOGIN = '/login.html';
|
||||||
|
|
||||||
|
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/')) {
|
||||||
|
e.respondWith(fetch(req));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.respondWith((async () => {
|
||||||
|
const token = self.__auth_token;
|
||||||
|
if (!token) {
|
||||||
|
// 尝试从 IndexedDB/localStorage 读取(SW 中无法直接访问 localStorage)
|
||||||
|
// 依赖客户端页面消息传递 token
|
||||||
|
return fetch(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.set('Authorization', 'Basic ' + token);
|
||||||
|
const res = await fetch(new Request(req, { headers }));
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (e.data && e.data.type === 'CLEAR_TOKEN') {
|
||||||
|
self.__auth_token = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
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 { CONTENT_DIR, BLOG_ROOT } from "@/lib/config";
|
|
||||||
import fs from "fs";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// Debug logging
|
|
||||||
console.log("[api/posts] BLOG_ROOT:", BLOG_ROOT);
|
|
||||||
console.log("[api/posts] CONTENT_DIR:", CONTENT_DIR);
|
|
||||||
console.log("[api/posts] cwd:", process.cwd());
|
|
||||||
console.log("[api/posts] env BLOG_ROOT:", process.env.BLOG_ROOT);
|
|
||||||
console.log("[api/posts] CONTENT_DIR exists:", fs.existsSync(CONTENT_DIR));
|
|
||||||
|
|
||||||
cleanExpired(); // housekeeping on each request
|
cleanExpired(); // housekeeping on each request
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
@@ -24,11 +15,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const result = listPosts({ search, page, perPage, includeDrafts });
|
const result = listPosts({ search, page, perPage, includeDrafts });
|
||||||
const totalCount = getPostCount();
|
const totalCount = getPostCount();
|
||||||
|
|
||||||
console.log("[api/posts] result:", result.total, "posts");
|
|
||||||
|
|
||||||
return NextResponse.json({ ...result, totalCount });
|
return NextResponse.json({ ...result, totalCount });
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error("[api/posts] error:", error);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : "Unknown error" },
|
{ error: error instanceof Error ? error.message : "Unknown error" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
|
|||||||
Reference in new issue
Block a user