diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index 00910698..8f75b7f1 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -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) || ''; } diff --git a/blog-admin/wrangler.toml b/blog-admin/wrangler.toml index 1a868d5c..87255a8f 100644 --- a/blog-admin/wrangler.toml +++ b/blog-admin/wrangler.toml @@ -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 diff --git a/deploy/editor-api/bootstrap.sh b/deploy/editor-api/bootstrap.sh index 8c30cc3c..f8bca47e 100644 --- a/deploy/editor-api/bootstrap.sh +++ b/deploy/editor-api/bootstrap.sh @@ -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" <process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 30s diff --git a/docker-compose.editor.yml b/docker-compose.editor.yml index 1a3155c2..7126bf63 100644 --- a/docker-compose.editor.yml +++ b/docker-compose.editor.yml @@ -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 diff --git a/editor-api/src/posts.mjs b/editor-api/src/posts.mjs index ff9ecd53..2239279a 100644 --- a/editor-api/src/posts.mjs +++ b/editor-api/src/posts.mjs @@ -292,6 +292,49 @@ export function makePosts(cfg) { } /** 删除 = 移到仓库外的回收目录(不进 git,也不误伤版本历史) */ + /** + * 递归复制目录树。 + * + * ★ 为什么不用 fs.cpSync:它在 Windows 上遇到**非 ASCII 目录名**会直接把 Node + * 进程干崩(实测 exit 127,连异常都不抛,日志停在调用前一行,stdout 缓冲丢失)。 + * 而文章目录名几乎必然含中文(`<日期>-<中文标题>-`),所以自己走一遍 —— + * 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 }; }