完成运行配置目录迁移并在设置菜单样式优化前建立检查点
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-05 20:58:18 +08:00
parent 3c455186b7
commit e8e14236db
7 changed files with 212 additions and 24 deletions

View File

@ -0,0 +1,91 @@
package res_init
import (
"bytes"
"os"
"path/filepath"
"testing"
"git.pcux.cn/xuans/Smail/config"
)
func TestDirInitUsesRuntimeRoot(t *testing.T) {
root, working := t.TempDir(), t.TempDir()
t.Chdir(working)
previous := config.ROOT_PATH
config.ROOT_PATH = root
t.Cleanup(func() { config.ROOT_PATH = previous })
for i := 0; i < 2; i++ {
if err := dirInit(); err != nil {
t.Fatal(err)
}
for _, name := range []string{"config", "config/dkim", "config/ssl"} {
info, err := os.Stat(filepath.Join(root, name))
if err != nil || !info.IsDir() {
t.Fatalf("运行目录未创建: %s", name)
}
}
}
if _, err := os.Stat(filepath.Join(working, "config")); !os.IsNotExist(err) {
t.Fatal("初始化不应在工作目录创建另一份配置")
}
}
func TestDirInitPreservesExistingFiles(t *testing.T) {
root := t.TempDir()
previous := config.ROOT_PATH
config.ROOT_PATH = root
t.Cleanup(func() { config.ROOT_PATH = previous })
if err := dirInit(); err != nil {
t.Fatal(err)
}
files := []string{"config/config.json", "config/smail.db", "config/credential.key", "config/dkim/dkim.priv", "config/ssl/private.key"}
data := []byte("existing-runtime-fixture")
for _, name := range files {
if err := os.WriteFile(filepath.Join(root, name), data, 0600); err != nil {
t.Fatal(err)
}
}
if err := dirInit(); err != nil {
t.Fatal(err)
}
for _, name := range files {
actual, err := os.ReadFile(filepath.Join(root, name))
if err != nil || !bytes.Equal(actual, data) {
t.Fatalf("初始化改变了已有运行文件: %s", name)
}
}
}
func TestDirInitRejectsFileAsDirectory(t *testing.T) {
root := t.TempDir()
previous := config.ROOT_PATH
config.ROOT_PATH = root
t.Cleanup(func() { config.ROOT_PATH = previous })
if err := os.Mkdir(filepath.Join(root, "config"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "config", "dkim"), []byte("blocked"), 0600); err != nil {
t.Fatal(err)
}
if err := dirInit(); err == nil {
t.Fatal("普通文件不能冒充运行目录")
}
}
func TestDirInitRejectsSymlink(t *testing.T) {
root, outside := t.TempDir(), t.TempDir()
previous := config.ROOT_PATH
config.ROOT_PATH = root
t.Cleanup(func() { config.ROOT_PATH = previous })
if err := os.Symlink(outside, filepath.Join(root, "config")); err != nil {
t.Skipf("当前环境无法创建符号链接: %v", err)
}
if err := dirInit(); err == nil {
t.Fatal("运行配置目录不能通过符号链接逃逸")
}
if _, err := os.Stat(filepath.Join(outside, "dkim")); !os.IsNotExist(err) {
t.Fatal("拒绝符号链接前不应写入外部目录")
}
}

View File

@ -32,7 +32,6 @@ import (
"git.pcux.cn/xuans/Smail/session"
"git.pcux.cn/xuans/Smail/signal"
"git.pcux.cn/xuans/Smail/utils/credential"
"git.pcux.cn/xuans/Smail/utils/file"
log "github.com/sirupsen/logrus"
// 导入 HTTP 客户端,用于等待服务启动。
"net/http"
@ -453,24 +452,18 @@ func stopAdminResetControl() {
}
func dirInit() error {
if !file.PathExist("./config") {
err := os.MkdirAll("./config", 0700)
if err != nil {
return fmt.Errorf("create config directory: %w", err)
// 初始化与配置读写使用同一个运行根目录,不依赖启动时的工作目录。
root := filepath.Join(config.ROOT_PATH, "config")
for _, directory := range []string{root, filepath.Join(root, "dkim"), filepath.Join(root, "ssl")} {
if err := os.MkdirAll(directory, 0700); err != nil {
return fmt.Errorf("创建运行配置目录失败: %w", err)
}
}
if !file.PathExist("./config/dkim") {
err := os.MkdirAll("./config/dkim", 0700)
info, err := os.Lstat(directory)
if err != nil {
return fmt.Errorf("create DKIM directory: %w", err)
return fmt.Errorf("检查运行配置目录失败: %w", err)
}
}
if !file.PathExist("./config/ssl") {
err := os.MkdirAll("./config/ssl", 0700)
if err != nil {
return fmt.Errorf("create SSL directory: %w", err)
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("运行配置目录不能是符号链接或普通文件")
}
}
return nil

View File

@ -11,10 +11,18 @@ try {
$binary = Join-Path $root 'out\backend\smail-dev.exe'
if (-not (Test-Path -LiteralPath $binary)) { throw '请先运行 deploy/build.ps1 编译后端' }
if (Get-NetTCPConnection -State Listen -LocalPort 443 -ErrorAction SilentlyContinue) { throw '443 端口已占用,请先确认并停止旧后端' }
# 二进制与配置分离后必须显式指定根目录,不能在 out 下初始化新数据库。
$env:SMAIL_ROOT = Join-Path $root 'backend'
# 拒绝沿用旧运行目录,避免迁移遗漏时静默创建另一份账号库。
$legacyConfig = Join-Path $root 'backend\config'
foreach ($name in @('config.json', 'config.dev.json', 'credential.key', 'smail.db', 'smail_temp.db')) {
if (Test-Path -LiteralPath (Join-Path $legacyConfig $name)) {
throw 'backend/config 中仍有运行文件,请先停止后端并完成根目录 config 迁移'
}
}
# SMAIL_ROOT 是 config 的父目录,不能指向 config 本身或编译输出目录。
$env:SMAIL_ROOT = $root
$arguments = @('--dev-disable-admin-reset')
$workingDirectory = $env:SMAIL_ROOT
# 插件工作目录保持不变;配置、数据库和密钥统一通过 SMAIL_ROOT 定位。
$workingDirectory = Join-Path $root 'backend'
} else {
if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' }
$binary = (Get-Command node -ErrorAction Stop).Source

View File

@ -24,17 +24,20 @@ powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Servic
2026-09-05 配置管理修复后,当前 `config/config.json` 已迁移为分区根配置。必须先执行上面的编译命令,再运行新二进制;不要直接运行迁移前保留的旧程序。备份和本次验收边界见《配置管理接入修复验收》。后续修复期间已成功普通权限停止旧后端并启动新后端,下方早期停止受阻记录仅反映当时状态。
启动脚本以普通权限在后台运行服务,并输出进程编号;停止时先核对进程路径,再使用 `Stop-Process -Id <进程编号>`。日志位于 `test-log/runtime/`,二进制位于 `out/backend/`。脚本显式设置 `SMAIL_ROOT` 指向 `backend/`,避免在 `out/` 下生成另一份配置或数据库。其他端口占用、文件权限或系统限制仍需分别处理,此参数不保证绕过所有操作系统权限问题。
启动脚本以普通权限在后台运行服务,并输出进程编号;停止时先核对进程路径,再使用 `Stop-Process -Id <进程编号>`。日志位于 `test-log/runtime/`,二进制位于 `out/backend/`。脚本显式设置 `SMAIL_ROOT` 指向项目根目录 `D:\Desktop\Smail`,运行文件统一位于根目录 `config/`,避免在 `backend/` 或 `out/` 下生成另一份配置或数据库。若旧 `backend/config/` 中仍有配置、密钥或数据库,脚本拒绝启动,要求先完成迁移。其他端口占用、文件权限或系统限制仍需分别处理,此参数不保证绕过所有操作系统权限问题。
## 原有开发模式
```powershell
Set-Location D:\Desktop\Smail\backend
$env:SMAIL_ROOT = 'D:\Desktop\Smail'
go run . dev
```
末尾 `dev` 仍读取 `config/config.dev.json` 并启用项目原有开发行为,现在也会停用管理员重置通道。该文件可能使用另一份数据库,不应为解决权限问题直接切换过去。开发模式下不再使用 `SMAIL_ADMIN_CONTROL_PATH` 创建重置通道。
`SMAIL_ROOT` 必须是 `config/` 的父目录,不能设置成 `D:\Desktop\Smail\config`,否则会变成 `config/config/`。直接运行 `out/backend/` 下的程序或管理工具时也应显式设置该变量;默认可执行文件目录规则为独立正式部署保留,不自动猜测源码位置。
## 正式部署
- 移除 `--dev-disable-admin-reset`,不要以末尾 `dev` 启动。

View File

@ -9,7 +9,8 @@
```text
Smail/
├── frontend/ # 前端源码与依赖
├── backend/ # 后端源码、必要的同包测试与运行配置
├── backend/ # 后端源码与必要的同包测试
├── config/ # 本机运行配置、数据库、证书、密钥与配置备份
├── plugins/ # 插件源码与文档
├── deploy/ # 构建、开发启动及正式部署工具
├── test/ # 独立测试源码、脚本和统一测试入口
@ -39,7 +40,7 @@ Smail/
1. Go 同包测试保留在 `backend/`:依赖包私有符号,直接搬离会破坏测试,统一入口负责集中执行。
2. `backend/listen/http_server/dist/` 和 Telegram 插件的 `hook/dist/` 是 Go 嵌入中间目录:Go 不允许跨模块向上嵌入,不能直接指向 `out/`。前端正式输出在 `out/`,构建后自动同步必需的嵌入文件。
3. 运行 Logo、文档图片、源代码静态资源、依赖目录、模型及训练工具不属于测试垃圾。
4. `backend/config/` 是现有服务的数据根目录,配置、SQLite、密钥及迁移备份原位保留。根目录 `ssl/` 和用途尚未确认的空数据库也保留,不凭文件名删除。
4. 后续按用户要求将运行文件统一迁入根目录 `config/`;`backend/config/` 只保留配置管理源码、同包测试和说明文件。迁移不改写分区引用、不清空数据库、不重新生成密钥。根目录 `ssl/` 是人工证书输入,用途尚未确认的空数据库也保留,不凭文件名删除。具体范围见《运行文件目录迁移说明》。
## 构建与启动
@ -50,7 +51,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Servic
powershell -NoProfile -ExecutionPolicy Bypass -File deploy/start-dev.ps1 -Service frontend
```
开发后端为 `out/backend/smail-dev.exe`。脚本固定 `SMAIL_ROOT=backend/`,不在 `out/` 下创建新账号库。编译前应停止正在运行的同名二进制,停止前核对进程号和路径。正式部署仍需使用发行入口 `cmd/smail`,保留本机管理员恢复通道的权限保护。
开发后端为 `out/backend/smail-dev.exe`。脚本固定 `SMAIL_ROOT` 为项目根目录,读取根目录 `config/`,不在 `out/` 下创建新账号库。插件工作目录保持原有 `backend/` 不变,配置初始化不再依赖工作目录。编译前应停止正在运行的同名二进制,停止前核对进程号和路径。正式部署仍需使用发行入口 `cmd/smail`,保留本机管理员恢复通道的权限保护。
两个新增 PowerShell 脚本使用 UTF-8 BOM,使 Windows PowerShell 5 正确解析中文;其他源文件沿用其原编码。没有用脚本替换中文文字。

View File

@ -0,0 +1,89 @@
# 运行文件目录迁移说明
## 需求确认
运行配置统一放在 `D:\Desktop\Smail\config`;`backend/config` 是 Go 配置管理源码包,不整体重命名或删除。运行日志继续放在 `test-log/runtime`,编译输出继续放在 `out`。
## 文件归属
```text
Smail/
├── config/ # 忽略 Git 的本机运行文件
│ ├── config.json # 当前启动使用的分区根配置
│ ├── smtp.json # SMTP 分区
│ ├── email.json # 邮箱策略分区
│ ├── ssl.json # 证书引用分区
│ ├── plugins.json # 插件配置分区
│ ├── config.dev.json # 历史 dev 模式配置,不是当前启动配置
│ ├── config_mysql.json # 保留的另一份配置,不自动启用
│ ├── config.json.legacy # 历史配置原始备份
│ ├── smail_temp.db # 当前账号及邮件数据库
│ ├── smail.db # 另一份已有 SQLite 数据库,保留
│ ├── credential.key # 凭据加密主密钥
│ ├── external-email-*.key # 外部邮箱加密和指纹密钥
│ ├── dkim/ # DKIM 密钥及备份
│ ├── ssl/ # TLS 证书、私钥及 ACME 资料
│ ├── migration-backups/ # 配置格式迁移备份
│ ├── backups-v2/ # 分区配置备份
│ └── transaction/ # 配置事务工作目录
└── backend/config/ # Go 源码、同包测试和原有说明文档
```
## 启动约定
- `deploy/start-dev.ps1` 设置 `SMAIL_ROOT=D:\Desktop\Smail`。程序使用 `SMAIL_ROOT/config` 读取和生成运行文件,不能把环境变量设置成 `config` 自身。
- 当前启动继续使用 `--dev-disable-admin-reset`,读取 `config/config.json`,不切换到 `config.dev.json` 或另一份数据库。
- 为保持已有插件安装路径,子进程工作目录仍是 `backend/`。初始化目录已改为基于 `SMAIL_ROOT`,不再向工作目录写入 `./config`。
- 直接运行编译程序或 `go run` 时也需要显式设置 `SMAIL_ROOT`。独立正式部署保留原有环境变量优先、可执行文件目录回退规则。
- 如果 `backend/config` 仍存在旧配置、主密钥或 SQLite 文件,开发启动脚本停止并提示先迁移,防止误初始化第二份账号库。
## 迁移和保护边界
1. 修改前提交中文检查点并推送,真实运行文件不进入 Git。
2. 停止并核实当前项目后端进程,前端保持运行。
3. 迁移前记录源码及运行文件清单和 SHA-256;运行文件另存一份本地回退副本到 `test-log/archive`。
4. 只迁移明确的运行文件,目标存在同名文件时拒绝覆盖;保留 Go 源码及说明文档,不删除旧空目录。
5. JSON、数据库、密钥及证书保持原始字节;分区配置的摘要和相对路径无需更改。
6. 迁移后逐一校验哈希,再启动后端并核对运行根目录、数据库及探活结果。
7. 根目录 `ssl/` 的人工输入证书、`backend/smail.db` 的原有空文件、历史测试配置不属于当前活动配置,不删除,也不冒充当前数据源。
本次是保留数据的目录迁移,不进行清库初始化。自动测试使用 `test-log` 下独立临时数据库;业务数据库仅做只读完整性和账号摘要校验。进程启动后的正常任务可能更新业务数据库,迁移字节校验应在重启前完成。
## 任务清单
- [✓] 核实运行文件、活动数据库与源码边界。
- [✓] 修改前检查点已提交并推送。
- [✓] 开发启动指向项目根目录,并拒绝旧目录残留运行文件;脚本语法、编码及路径约束测试通过。
- [✓] 初始化目录与工作目录解耦;异工作目录、重复初始化、文件保留和目录冲突回归通过。
- [✓] 停服备份并迁移 24 个运行文件,重启前逐一哈希一致;55 个源码及说明文件保持不变。
- [✓] 三个后端程序重新编译,前端 19 项自动测试、Go 全量测试及静态检查通过。
- [✓] 从新目录恢复后端,两份 SQLite 完整性及迁移前表记录数一致,账号记录在重启前后均未改变;前后端探活正常。
- [!] 符号链接拒绝测试在当前 Windows 普通账户下因缺少创建符号链接权限而跳过,未提升权限,也未据此宣称该分支实测通过。
## 本次验收记录
2026-09-05 本地迁移结果:
- 修改前检查点:`3c455186b70289238167a19432431ace74ee79e4`,已核对与远端 `smail/main` 一致;后续本轮代码改动保留在工作区。
- 停止的旧后端进程编号为 `23136`;新进程编号为 `45920`,可执行文件位于 `out/backend/smail-dev.exe`。进程编号仅代表验收当时状态。
- 新启动日志显示 `Env Root Path: D:\Desktop\Smail/`,当前配置仍是 `config/config.json`,数据库仍由 `localRef=smail_temp.db` 指定,分区代次保持 `1`。
- 两个 SQLite 文件各有 20 张表;保留库 `smail.db` 有 0 个账号,活动库 `smail_temp.db` 有 1 个账号。校验不输出账号、密码哈希或密钥内容。
- 重启后 23 个非活动数据库运行文件及 55 个源码说明文件哈希仍一致;活动数据库仅允许正常运行写入,账号完整记录与备份比较一致。
- `https://127.0.0.1/api/ping` 和 `http://127.0.0.1:15173/api/ping` 均返回 HTTP 200、`errorNo=0`、`pong`;前端页面返回 200。
- `https://127.0.0.1/config/credential.key` 返回 404,没有把新运行目录当作静态资源公开。自签名证书探活使用跳过本机证书信任验证参数,不代表公开 CA 信任验收。
- `node test/run.mjs all -- -count=1 -p 2 ./...` 通过;随后单独复跑 `TestDirInit`,三个用例通过、符号链接用例跳过。
- `go -C backend vet -p 2 ./...`、PowerShell 语法检查、`git diff --check` 和 Go 格式检查通过;启动脚本仍保留 Windows PowerShell 5 需要的 UTF-8 BOM。
- Git 忽略规则确认根目录配置、密钥、数据库、回退副本、日志和编译产物不会进入提交。
本地回退副本:`test-log/archive/runtime-config-20260905-202201/`。目录包含密钥和数据库,仅留在本机,不上传。
检查证据:
- `test-log/reports/runtime-config-migration-20260905-202201.json`:完整移动、哈希和源码保留清单。
- `test-log/reports/runtime-config-sqlite-before-start.json`:重启前数据库完整性、记录数及账号一致性。
- `test-log/reports/runtime-config-after-start.json`:重启后路径、运行文件与账号一致性。
- `test-log/reports/runtime-config-build-20260905.log`:本次后端及管理工具构建记录。
- `test-log/run-2026-09-05T12-20-49-905Z-23176/`:完整自动测试记录。
- `test-log/run-2026-09-05T12-24-16-351Z-24952/`:初始化目录专项回归记录。
真实邮件收发、浏览器全部流程和 Linux/Windows 正式安装不在本次迁移验收范围内,不能以探活成功代替这些验收。

View File

@ -28,7 +28,10 @@ test('前端构建必须自动同步 Go 嵌入资源', async () => {
test('开发启动必须固定运行配置根目录并集中保存日志', async () => {
const content = await read('deploy/start-dev.ps1')
assert.match(content, /\$env:SMAIL_ROOT = Join-Path \$root 'backend'/)
assert.match(content, /\$env:SMAIL_ROOT = \$root\r?\n/)
assert.match(content, /\$legacyConfig = Join-Path \$root 'backend\\config'/)
assert.match(content, /Test-Path -LiteralPath \(Join-Path \$legacyConfig \$name\)/)
assert.match(content, /\$workingDirectory = Join-Path \$root 'backend'/)
assert.match(content, /out\\backend\\smail-dev\.exe/)
assert.match(content, /test-log\\runtime/)
assert.match(content, /-WindowStyle Hidden/)