fix(editor): 删文章 EXDEV 修正 + 订阅口令不再阻断登录
1) 删除文章报 EXDEV(cross-device link not permitted)
· 根因一:容器内 /blog 与 /app/trash 是两个独立 bind mount,
rename(2) 跨挂载点必然 EXDEV —— 哪怕宿主机上它们同在一块盘。
· 根因二:退化成 fs.cpSync 后,Windows 上遇到**中文目录名**会把
Node 进程直接干崩(exit 127,不抛异常,stdout 缓冲丢失)。
而文章目录名几乎必然含中文(<日期>-<中文标题>-<slug>)。
· 修法:改用 readdirSync/copyFileSync 自己递归复制(宽字符路径,中文 OK);
bootstrap.sh / compose 改成挂 BLOG_DIR 的父目录,让仓库与回收站
落在**同一个挂载点**内,正常情况下 rename 直接成功、不再复制。
2) 登录页「订阅口令」填错会阻断整个登录
· 它只是可选订阅模块的口令,不该 throw 掉登录流程。
· rssApi() 无条件 searchParams.set('token', RSS_TOKEN),会用浏览器里
记住的旧口令覆盖调用方刚传的值 → 正确口令也验不过。
· 现在:填错只 toast 提示 + 清掉失效旧口令;rssApi 尊重调用方传的值。
3) wrangler.toml:EDITOR_API_BASE 改指国内机新域名 writeapi.usj.cc
(原指向即将到期的境外机 post.usj.cc,且那台从未部署过 editor-api)
验证:api-e2e 44/44;front matter 往返 130/130;保存往返 130/130;
中文路径删除实测通过(目录 + 中文子目录 + 图片全部移入回收站,进程不崩)。
This commit is contained in:
1 parent
e8d13b9a6e
commit
96751b8143
5 files changed
+98
-18
No files matched your search
@@ -136,7 +136,9 @@ let RSS_TOKEN = localStorage.getItem(LS_RSS) || '';
|
||||
async function rssApi(path, opt) {
|
||||
opt = opt || {};
|
||||
const u = new URL(RSS_API + path);
|
||||
if (RSS_TOKEN) u.searchParams.set('token', RSS_TOKEN);
|
||||
// 调用方显式带了 token 就用它,否则补本地记住的口令。
|
||||
// (以前无条件 set 会覆盖调用方传的值,导致「刚输入的口令被旧口令盖掉」)
|
||||
if (!u.searchParams.get('token') && RSS_TOKEN) u.searchParams.set('token', RSS_TOKEN);
|
||||
|
||||
const headers = {};
|
||||
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
@@ -1871,13 +1873,24 @@ $('#loginForm').addEventListener('submit', async (e) => {
|
||||
state.me = state.me || {};
|
||||
if (state.me.is_admin === false) throw new Error('该账号不是管理员');
|
||||
|
||||
// 订阅口令:填了就验证并存下,没填就清空(订阅模块会提示未配置)
|
||||
// 订阅口令是**可选**的:填了就验证,错了只影响订阅模块,绝不阻断登录
|
||||
const rssPwd = $('#lgRss') ? $('#lgRss').value.trim() : '';
|
||||
if (rssPwd) {
|
||||
const ok = await rssApi('/api/auth?token=' + encodeURIComponent(rssPwd));
|
||||
if (!ok || ok.ok !== true) throw new Error('订阅口令错误');
|
||||
RSS_TOKEN = rssPwd;
|
||||
localStorage.setItem(LS_RSS, rssPwd);
|
||||
// 直接验证刚输入的口令(不走 rssApi,避免被本地记住的旧口令覆盖)
|
||||
let rssOk = false;
|
||||
try {
|
||||
const r = await fetch(RSS_API + '/api/auth?token=' + encodeURIComponent(rssPwd));
|
||||
const d = await r.json().catch(() => null);
|
||||
rssOk = !!(d && d.ok === true);
|
||||
} catch (e) { rssOk = false; }
|
||||
if (rssOk) {
|
||||
RSS_TOKEN = rssPwd;
|
||||
localStorage.setItem(LS_RSS, rssPwd);
|
||||
} else {
|
||||
RSS_TOKEN = '';
|
||||
localStorage.removeItem(LS_RSS);
|
||||
setTimeout(() => toast('订阅口令不正确,已跳过订阅模块(不影响其它功能)', true), 400);
|
||||
}
|
||||
} else {
|
||||
RSS_TOKEN = localStorage.getItem(LS_RSS) || '';
|
||||
}
|
||||
|
||||
@@ -46,7 +46,9 @@ FEEDS_URL = ""
|
||||
FEISHU_WEBHOOK_URL = ""
|
||||
# 文章编辑后端(自建 editor-api 容器,Worker 只做鉴权+反代)。
|
||||
# 留空 = 模块自动禁用(/api/v2/editor/* 一律 503)。令牌另走 secret,别写这里。
|
||||
EDITOR_API_BASE = "https://post.usj.cc/editor-api"
|
||||
# writeapi.usj.cc → 国内机 119.29.215.187 的 nginx 反代 → 127.0.0.1:8017 容器
|
||||
# (换机时只改这条 + 新机重跑 deploy/editor-api/bootstrap.sh,域名指向新机即可)
|
||||
EDITOR_API_BASE = "https://writeapi.usj.cc"
|
||||
|
||||
# ⚠️ 下面两个是机密,不要写在这里,用命令写入(详见「部署清单.md」第 4 步):
|
||||
# npx wrangler secret put TOKEN_SECRET
|
||||
|
||||
@@ -103,6 +103,25 @@ umask 077
|
||||
} > "$APP_DIR/.env"
|
||||
|
||||
say "4/5 生成 compose 并启动容器"
|
||||
|
||||
# ★ 仓库与回收站必须落在**同一个挂载点**里。
|
||||
# 若容器内把它们挂成两个独立 bind mount,rename(2) 跨挂载点必返回 EXDEV
|
||||
# ——哪怕宿主机上它们同在一块盘上。后端虽已能退化成「复制+删除」,但那是白搬一遍数据。
|
||||
# 做法:挂载 BLOG_DIR 的**父目录**,让容器内路径 == 宿主机路径。
|
||||
MOUNT_ROOT="$(dirname "$BLOG_DIR")"
|
||||
if [ "${TRASH_DIR#"$MOUNT_ROOT"/}" != "$TRASH_DIR" ]; then
|
||||
# 回收站就在仓库父目录下 → 一个挂载点搞定
|
||||
BLOG_IN_CT="$BLOG_DIR"
|
||||
TRASH_IN_CT="$TRASH_DIR"
|
||||
VOLUMES=" - $MOUNT_ROOT:$MOUNT_ROOT"
|
||||
else
|
||||
# 回收站被指到别处 → 只能两条独立挂载(后端会自动复制+删除,只是慢一点)
|
||||
BLOG_IN_CT=/blog
|
||||
TRASH_IN_CT=/app/trash
|
||||
VOLUMES=" - $BLOG_DIR:/blog
|
||||
- $TRASH_DIR:/app/trash"
|
||||
fi
|
||||
|
||||
cat > "$APP_DIR/docker-compose.yml" <<EOF
|
||||
# 本文件由 bootstrap.sh 生成 —— 想改配置请改 bootstrap.sh 或 .env 后重跑
|
||||
services:
|
||||
@@ -115,8 +134,8 @@ services:
|
||||
- "127.0.0.1:$BIND_PORT:8017"
|
||||
environment:
|
||||
EDITOR_TOKEN: \${EDITOR_TOKEN:?}
|
||||
BLOG_ROOT: /blog
|
||||
TRASH_DIR: /app/trash
|
||||
BLOG_ROOT: $BLOG_IN_CT
|
||||
TRASH_DIR: $TRASH_IN_CT
|
||||
BIND_HOST: 0.0.0.0
|
||||
GIT_BRANCH: main
|
||||
PUSH_REMOTES: \${PUSH_REMOTES:-origin}
|
||||
@@ -126,8 +145,7 @@ services:
|
||||
MAX_UPLOAD_MB: "20"
|
||||
GIT_PATHS: content,static
|
||||
volumes:
|
||||
- \$BLOG_DIR:/blog
|
||||
- \$TRASH_DIR:/app/trash
|
||||
$VOLUMES
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8017/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
|
||||
@@ -20,8 +20,10 @@ services:
|
||||
environment:
|
||||
# 令牌:必须显式给,没给容器会直接退出(server.mjs 里故意的)
|
||||
EDITOR_TOKEN: ${EDITOR_TOKEN:?请在同目录 .env 里设置 EDITOR_TOKEN}
|
||||
BLOG_ROOT: /blog
|
||||
TRASH_DIR: /app/trash
|
||||
BLOG_ROOT: /srv/blog
|
||||
# ★ 回收站与仓库必须在**同一个挂载点**内,否则 rename 会 EXDEV
|
||||
# (见下方 volumes 注释)。所以这里用宿主机路径,不再映射到 /app/trash。
|
||||
TRASH_DIR: /srv/editor-trash
|
||||
# 容器内监听 0.0.0.0 没关系 —— 外面那层端口映射已经把它锁在宿主机回环上了
|
||||
BIND_HOST: 0.0.0.0
|
||||
GIT_BRANCH: main
|
||||
@@ -31,10 +33,12 @@ services:
|
||||
MAX_UPLOAD_MB: "20"
|
||||
GIT_PATHS: content,static
|
||||
volumes:
|
||||
# 博客仓库的 git 工作区(不是镜像里带的,改完才能 commit + push)
|
||||
- /srv/blog:/blog
|
||||
# 回收目录放仓库外面,避免在 git status 里冒出来
|
||||
- /srv/editor-trash:/app/trash
|
||||
# ★ 只挂**一个**父目录:仓库和回收站都在它下面。
|
||||
# 容器内路径 == 宿主机路径,两者同属一个挂载点,
|
||||
# 删文章时 rename(2) 才能原子完成。
|
||||
# 若分开挂成 /blog 和 /app/trash 两个 bind mount,rename 必报
|
||||
# EXDEV: cross-device link not permitted(哪怕宿主机同一块盘)。
|
||||
- /srv:/srv
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8017/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
|
||||
@@ -292,6 +292,49 @@ export function makePosts(cfg) {
|
||||
}
|
||||
|
||||
/** 删除 = 移到仓库外的回收目录(不进 git,也不误伤版本历史) */
|
||||
/**
|
||||
* 递归复制目录树。
|
||||
*
|
||||
* ★ 为什么不用 fs.cpSync:它在 Windows 上遇到**非 ASCII 目录名**会直接把 Node
|
||||
* 进程干崩(实测 exit 127,连异常都不抛,日志停在调用前一行,stdout 缓冲丢失)。
|
||||
* 而文章目录名几乎必然含中文(`<日期>-<中文标题>-<slug>`),所以自己走一遍 ——
|
||||
* readdirSync / copyFileSync / mkdirSync 用的是宽字符路径,中文没问题。
|
||||
*/
|
||||
function copyTree(src, dst) {
|
||||
const st = fs.lstatSync(src);
|
||||
if (st.isDirectory()) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const name of fs.readdirSync(src)) {
|
||||
copyTree(path.join(src, name), path.join(dst, name));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (st.isSymbolicLink()) {
|
||||
fs.symlinkSync(fs.readlinkSync(src), dst);
|
||||
return;
|
||||
}
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把文章目录挪进回收站。
|
||||
*
|
||||
* ★ 不能直接用 rename:仓库挂载点(容器内 /blog)和回收站挂载点(/app/trash)
|
||||
* 是**两个独立的 bind mount**,rename(2) 跨挂载点必然返回 EXDEV
|
||||
* —— 哪怕在宿主机上它们同在一块盘上。
|
||||
* (bootstrap.sh 已改成把两者放进同一个挂载点,正常情况下走的是 rename 分支,
|
||||
* 这里的复制分支只是兜底,比如有人手动改了挂载方式。)
|
||||
*/
|
||||
function moveToTrash(src, dst) {
|
||||
try {
|
||||
fs.renameSync(src, dst);
|
||||
} catch (e) {
|
||||
if (e.code !== 'EXDEV') throw e;
|
||||
copyTree(src, dst);
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function deletePost(key) {
|
||||
const post = getPost(key);
|
||||
if (!post) return null;
|
||||
@@ -299,7 +342,7 @@ export function makePosts(cfg) {
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const dest = path.join(trashDir, stamp + '-' + path.basename(dir));
|
||||
fs.mkdirSync(trashDir, { recursive: true });
|
||||
fs.renameSync(dir, dest);
|
||||
moveToTrash(dir, dest);
|
||||
return { id: post.dirName, slug: post.slug, movedTo: dest };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user