diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ca4c4ef..dbcde0e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,21 +55,21 @@ jobs: echo "SPAMBLOCKFILENAME=spam_block_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} echo "TELEGRAMFILENAME=pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe" >> ${GITHUB_ENV} - name: FE Build - run: cd fe && yarn install --frozen-lockfile && yarn build + run: cd frontend && yarn install --frozen-lockfile && yarn build - name: BE Build run: | - cd server - cp -rf ../fe/dist listen/http_server + mkdir -p out/release/plugins + cd backend BUILD_FLAGS="-s -w -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.Version=${{ env.VERSION }}' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GoVersion=$(go version)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.GitHash=$(git show -s --format=%H)' -X 'git.pcux.cn/xuans/Smail/internal/buildinfo.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)'" - go build -ldflags "$BUILD_FLAGS" -o ${{ env.FILENAME }} ./cmd/smail - go build -ldflags "$BUILD_FLAGS" -o ${{ env.ADMINFILENAME }} ./cmd/smail-admin - go build -ldflags "-s -w" -o ${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go - go build -ldflags "-s -w" -o ${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go + go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.FILENAME }} ./cmd/smail + go build -ldflags "$BUILD_FLAGS" -o ../out/release/${{ env.ADMINFILENAME }} ./cmd/smail-admin + go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.WCFILENAME }} hooks/wechat_push/wechat_push.go + go build -ldflags "-s -w" -o ../out/release/plugins/${{ env.SPAMBLOCKFILENAME }} hooks/spam_block/spam_block.go cd ../plugins/pmail_telegram_push npm ci npm run build-only - go build -ldflags "-s -w" -o ../../server/${{ env.TELEGRAMFILENAME }} . - cd ../../server + go build -ldflags "-s -w" -o ../../out/release/plugins/${{ env.TELEGRAMFILENAME }} . + cd ../../out/release cat > release.json </dev/null || true - cp -r server/config/ssl output/config/ - cp -r server/config/config.json output/config/ - mv server/hooks/spam_block/output/* output/plugins - mv server/hooks/wechat_push/output/* output/plugins - mv plugins/pmail_telegram_push/output/* output/plugins - cp README.md output/ - printf '{\n "version": "%s",\n "gitHash": "%s",\n "protocolVersion": 1,\n "createdAt": "%s"\n}\n' "$(SMAIL_VERSION)" "$(GIT_HASH)" "$(BUILD_TIME)" > output/release.json +package: + mkdir -p out/package/config out/package/plugins + cp out/backend/smail* out/package/ + cp out/plugins/*_* out/package/plugins/ + cp README.md out/package/ + printf '{\n "version": "%s",\n "gitHash": "%s",\n "protocolVersion": 1,\n "createdAt": "%s"\n}\n' "$(SMAIL_VERSION)" "$(GIT_HASH)" "$(BUILD_TIME)" > out/package/release.json test: - @trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -v -p 1 ./... + node test/run.mjs all test_mysql: - @trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -args "mysql" -v -p 1 ./... + node test/run.mjs backend -v -p 1 ./... -args mysql test_postgres: - @trap 'cd "$(CURDIR)/server" && git clean -f -x -- .codex-smail-*' EXIT; export setup_port=17888 && cd server && export SMAIL_ROOT=$(CURDIR)/server/ && go test -args "postgres" -v -p 1 ./... + node test/run.mjs backend -v -p 1 ./... -args postgres diff --git a/README.md b/README.md index b007e45d..076ca24f 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,9 @@ IMAP端口: 993(SSL) # 插件 -[微信推送](server/hooks/wechat_push/README.md) +[微信推送](backend/hooks/wechat_push/README.md) -[垃圾邮件屏蔽](server/hooks/spam_block/README.md) +[垃圾邮件屏蔽](backend/hooks/spam_block/README.md) # 其他优秀开源插件 @@ -131,15 +131,17 @@ IMAP端口: 993(SSL) # 参与开发 +目录约定:独立测试代码和脚本位于 `test/`,测试日志及截图位于 `test-log/`,编译输出位于 `out/`。构建与开发启动方式见 [文件目录整理说明](docs/文件目录整理说明.md),测试入口见 [测试说明](test/README.md)。 + ## 项目架构 1、前端: vue3+element-plus -前端代码位于 `fe`目录中,运行参考 `fe`目录中的README文件 +前端代码位于 `frontend` 目录中,运行参考 `frontend` 目录中的 README 文件。 2、后端: golang + MySQL/SQLite -后端代码进入 `server`文件夹,运行 `main.go`文件 +后端代码位于 `backend/`;推荐使用 `deploy/build.ps1` 构建后,通过 `deploy/start-dev.ps1` 启动开发服务。 3、编译项目 diff --git a/README_CN.md b/README_CN.md index 28d2e44b..c8a30a46 100644 --- a/README_CN.md +++ b/README_CN.md @@ -160,9 +160,9 @@ PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail 因此,使用 Smail 邮件服务时,请让客户端直接连接 Smail 的 SMTP、IMAP、POP3 服务,并按本节的开关、完整邮箱地址和协议授权码规则配置;不要把 PMail 原版的账号截断或旧密码逻辑复制到 Smail。 # 插件 -[微信推送](server/hooks/wechat_push/README.md) +[微信推送](backend/hooks/wechat_push/README.md) -[垃圾邮件屏蔽](server/hooks/spam_block/README.md) +[垃圾邮件屏蔽](backend/hooks/spam_block/README.md) # 其他优秀开源插件 @@ -176,15 +176,17 @@ PMail 原版的协议登录流程与 Smail 不兼容,不能直接作为 Smail # 参与开发 +目录约定:独立测试代码和脚本位于 `test/`,测试日志及截图位于 `test-log/`,编译输出位于 `out/`。构建与开发启动方式见 [文件目录整理说明](docs/文件目录整理说明.md),测试入口见 [测试说明](test/README.md)。 + ## 项目架构 1、前端: vue3+element-plus -前端代码位于 `fe`目录中,运行参考 `fe`目录中的README文件 +前端代码位于 `frontend` 目录中,运行参考 `frontend` 目录中的 README 文件。 2、后端: golang + MySQL/SQLite -后端代码进入 `server`文件夹,运行 `main.go`文件 +后端代码位于 `backend/`;推荐使用 `deploy/build.ps1` 构建后,通过 `deploy/start-dev.ps1` 启动开发服务。 3、编译项目 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..7116ed42 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ + +!backend/config/ +!backend/config/*.go diff --git a/server/cmd/smail-admin/main.go b/backend/cmd/smail-admin/main.go similarity index 100% rename from server/cmd/smail-admin/main.go rename to backend/cmd/smail-admin/main.go diff --git a/server/cmd/smail-admin/main_test.go b/backend/cmd/smail-admin/main_test.go similarity index 100% rename from server/cmd/smail-admin/main_test.go rename to backend/cmd/smail-admin/main_test.go diff --git a/server/cmd/smail-admin/systemd_policy_test.go b/backend/cmd/smail-admin/systemd_policy_test.go similarity index 100% rename from server/cmd/smail-admin/systemd_policy_test.go rename to backend/cmd/smail-admin/systemd_policy_test.go diff --git a/server/cmd/smail-admin/windows_deployment_policy_test.go b/backend/cmd/smail-admin/windows_deployment_policy_test.go similarity index 100% rename from server/cmd/smail-admin/windows_deployment_policy_test.go rename to backend/cmd/smail-admin/windows_deployment_policy_test.go diff --git a/backend/cmd/smail-config/main.go b/backend/cmd/smail-config/main.go new file mode 100644 index 00000000..e62ed72e --- /dev/null +++ b/backend/cmd/smail-config/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "git.pcux.cn/xuans/Smail/config" +) + +// main 提供部署端显式配置迁移,不在网页或普通启动中自动改写配置。 +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + flags := flag.NewFlagSet("smail-config", flag.ContinueOnError) + root := flags.String("root", "", "Smail 服务根目录,包含 config 子目录") + apply := flags.Bool("apply", false, "确认已停止后端,备份并执行迁移;默认只检查") + if err := flags.Parse(args); err != nil { + return err + } + if *root == "" || flags.NArg() != 0 { + return fmt.Errorf("请指定 --root 服务根目录;迁移前必须停止后端") + } + absolute, err := filepath.Abs(*root) + if err != nil { + return fmt.Errorf("服务根目录无效") + } + options := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true} + plan, err := config.PlanLegacyMigration(absolute, options) + if err != nil { + // 不输出配置原文、未知字段或底层错误,避免凭据进入终端记录。 + return fmt.Errorf("迁移检查未通过:未知字段 %d 项,需人工处理字段 %d 项;未执行迁移", len(plan.UnknownFields), len(plan.ManualFields)) + } + if !*apply { + fmt.Println("旧版字段检查通过;尚未写入配置。执行迁移前请停止后端,使用 --apply 继续完整校验和备份迁移。") + return nil + } + result, err := config.MigrateLegacyConfig(absolute, options) + if err != nil { + return fmt.Errorf("配置迁移未完成,请保留原配置和备份,由部署管理员检查;不要手工删除分区文件") + } + fmt.Printf("配置迁移完成,代次:%d;原配置备份:%s。请使用最新版后端启动。\n", result.Generation, result.BackupPath) + return nil +} diff --git a/server/cmd/smail/main.go b/backend/cmd/smail/main.go similarity index 100% rename from server/cmd/smail/main.go rename to backend/cmd/smail/main.go diff --git a/server/cmd/smail/run_nonwindows.go b/backend/cmd/smail/run_nonwindows.go similarity index 100% rename from server/cmd/smail/run_nonwindows.go rename to backend/cmd/smail/run_nonwindows.go diff --git a/server/cmd/smail/run_windows.go b/backend/cmd/smail/run_windows.go similarity index 100% rename from server/cmd/smail/run_windows.go rename to backend/cmd/smail/run_windows.go diff --git a/server/config/acme_key.go b/backend/config/acme_key.go similarity index 100% rename from server/config/acme_key.go rename to backend/config/acme_key.go diff --git a/server/config/acme_key_test.go b/backend/config/acme_key_test.go similarity index 100% rename from server/config/acme_key_test.go rename to backend/config/acme_key_test.go diff --git a/server/config/audit_export.go b/backend/config/audit_export.go similarity index 100% rename from server/config/audit_export.go rename to backend/config/audit_export.go diff --git a/server/config/audit_query.go b/backend/config/audit_query.go similarity index 100% rename from server/config/audit_query.go rename to backend/config/audit_query.go diff --git a/server/config/audit_query_test.go b/backend/config/audit_query_test.go similarity index 100% rename from server/config/audit_query_test.go rename to backend/config/audit_query_test.go diff --git a/server/config/audit_store.go b/backend/config/audit_store.go similarity index 100% rename from server/config/audit_store.go rename to backend/config/audit_store.go diff --git a/server/config/audit_store_test.go b/backend/config/audit_store_test.go similarity index 100% rename from server/config/audit_store_test.go rename to backend/config/audit_store_test.go diff --git a/server/config/backup_list.go b/backend/config/backup_list.go similarity index 100% rename from server/config/backup_list.go rename to backend/config/backup_list.go diff --git a/server/config/config.go b/backend/config/config.go similarity index 92% rename from server/config/config.go rename to backend/config/config.go index 13edf0d8..dc8d5ad6 100644 --- a/server/config/config.go +++ b/backend/config/config.go @@ -172,18 +172,32 @@ func Init() error { } loaded := &Config{} - if err = json.Unmarshal(cfgData, loaded); err != nil { - return errors.Wrap(err) - } - applyLegacyLoginPolicy(cfgData, loaded) - migrated, err := unprotectCredentials(loaded) + partitioned, err := isPartitionedConfig(cfgData) if err != nil { - return errors.Wrap(err) + return err } - if migrated { - if err = writeProtectedConfigFile(configName, loaded); err != nil { + if partitioned { + if DevelopmentMode { + return fmt.Errorf("分区配置请通过 config.json 启动,不支持 config.dev.json") + } + loaded, err = loadPartitionedCompatibility() + if err != nil { + return err + } + } else { + if err = json.Unmarshal(cfgData, loaded); err != nil { return errors.Wrap(err) } + applyLegacyLoginPolicy(cfgData, loaded) + migrated, err := unprotectCredentials(loaded) + if err != nil { + return errors.Wrap(err) + } + if migrated { + if err = writeProtectedConfigFile(configName, loaded); err != nil { + return errors.Wrap(err) + } + } } Instance = loaded Instance.fixPath() @@ -265,6 +279,17 @@ func WriteConfig(cfg *Config) error { } func writeProtectedConfigFile(name string, cfg *Config) error { + if data, err := readConfigFile(name); err == nil { + partitioned, parseErr := isPartitionedConfig(data) + if parseErr != nil { + return parseErr + } + if partitioned { + return fmt.Errorf("分区配置不能通过旧版写入接口覆盖,请使用配置管理") + } + } else if !os.IsNotExist(err) { + return err + } stored, err := protectCredentials(cfg) if err != nil { return errors.Wrap(err) @@ -303,6 +328,13 @@ func ReadConfig() (*Config, error) { log.Errorf("Read Config Error:%s", err.Error()) return nil, errors.Wrap(err) } + partitioned, err := isPartitionedConfig(cfgData) + if err != nil { + return nil, err + } + if partitioned { + return loadPartitionedCompatibility() + } if err = json.Unmarshal(cfgData, &configData); err != nil { log.Error("Read Config Unmarshal Error") return nil, errors.Wrap(err) diff --git a/server/config/config_security_test.go b/backend/config/config_security_test.go similarity index 100% rename from server/config/config_security_test.go rename to backend/config/config_security_test.go diff --git a/server/config/database_migration.go b/backend/config/database_migration.go similarity index 100% rename from server/config/database_migration.go rename to backend/config/database_migration.go diff --git a/server/config/database_migration_test.go b/backend/config/database_migration_test.go similarity index 100% rename from server/config/database_migration_test.go rename to backend/config/database_migration_test.go diff --git a/server/config/dkim/README.md b/backend/config/dkim/README.md similarity index 100% rename from server/config/dkim/README.md rename to backend/config/dkim/README.md diff --git a/server/config/loader.go b/backend/config/loader.go similarity index 100% rename from server/config/loader.go rename to backend/config/loader.go diff --git a/server/config/manager.go b/backend/config/manager.go similarity index 100% rename from server/config/manager.go rename to backend/config/manager.go diff --git a/server/config/manager_backups.go b/backend/config/manager_backups.go similarity index 100% rename from server/config/manager_backups.go rename to backend/config/manager_backups.go diff --git a/server/config/manager_security_test.go b/backend/config/manager_security_test.go similarity index 100% rename from server/config/manager_security_test.go rename to backend/config/manager_security_test.go diff --git a/server/config/manager_test.go b/backend/config/manager_test.go similarity index 100% rename from server/config/manager_test.go rename to backend/config/manager_test.go diff --git a/server/config/manager_v2.go b/backend/config/manager_v2.go similarity index 100% rename from server/config/manager_v2.go rename to backend/config/manager_v2.go diff --git a/server/config/manager_v2_test.go b/backend/config/manager_v2_test.go similarity index 100% rename from server/config/manager_v2_test.go rename to backend/config/manager_v2_test.go diff --git a/server/config/migrate.go b/backend/config/migrate.go similarity index 100% rename from server/config/migrate.go rename to backend/config/migrate.go diff --git a/server/config/migrate_test.go b/backend/config/migrate_test.go similarity index 100% rename from server/config/migrate_test.go rename to backend/config/migrate_test.go diff --git a/server/config/model.go b/backend/config/model.go similarity index 100% rename from server/config/model.go rename to backend/config/model.go diff --git a/server/config/model_database_test.go b/backend/config/model_database_test.go similarity index 100% rename from server/config/model_database_test.go rename to backend/config/model_database_test.go diff --git a/server/config/model_test.go b/backend/config/model_test.go similarity index 100% rename from server/config/model_test.go rename to backend/config/model_test.go diff --git a/server/config/parser.go b/backend/config/parser.go similarity index 100% rename from server/config/parser.go rename to backend/config/parser.go diff --git a/server/config/parser_security_test.go b/backend/config/parser_security_test.go similarity index 100% rename from server/config/parser_security_test.go rename to backend/config/parser_security_test.go diff --git a/server/config/parser_test.go b/backend/config/parser_test.go similarity index 100% rename from server/config/parser_test.go rename to backend/config/parser_test.go diff --git a/backend/config/partitioned_compatibility.go b/backend/config/partitioned_compatibility.go new file mode 100644 index 00000000..cb981689 --- /dev/null +++ b/backend/config/partitioned_compatibility.go @@ -0,0 +1,111 @@ +package config + +import ( + "encoding/json" + "fmt" + "net" + "net/url" + "path/filepath" + "strconv" + "strings" + + "git.pcux.cn/xuans/Smail/utils/credential" + "github.com/go-sql-driver/mysql" +) + +// isPartitionedConfig 在旧版解码前识别分区根文件,避免静默丢失数据库等字段。 +func isPartitionedConfig(data []byte) (bool, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return false, err + } + _, sections := fields["sections"] + _, database := fields["database"] + return sections || database, nil +} + +// loadPartitionedCompatibility 仅构造旧业务所需的只读快照,不重写分区配置。 +func loadPartitionedCompatibility() (*Config, error) { + runtime, err := NewConfigLoader(ROOT_PATH, ConfigLoaderOptions{ + Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: !DevelopmentMode}, + }).Load() + if err != nil { + return nil, err + } + vault, err := credential.NewVault(ROOT_PATH) + if err != nil { + return nil, err + } + if err := ValidateRuntimeSecrets(vault, runtime); err != nil { + return nil, err + } + driver, dsn, err := compatibilityDatabase(runtime.Root.Database, vault) + if err != nil { + return nil, err + } + root := runtime.Root + cfg := &Config{ + LogLevel: root.LogLevel, Domain: root.Domains.Primary, + Domains: append([]string(nil), root.Domains.Accepted...), WebDomain: root.Domains.Web, + AccessHosts: append([]string(nil), root.Domains.AccessHosts...), + DbType: driver, DbDSN: dsn, SetupState: runtime.SetupState, + IsInit: runtime.SetupState == SetupStateReady, + HttpPort: root.HTTP.Port, HttpsPort: root.HTTP.HTTPSPort, + HttpsEnabled: 2, TLSMode: root.HTTP.TLSMode, + TrustedProxyCIDRs: append([]string(nil), root.HTTP.TrustedProxyCIDRs...), + DkimPrivateKeyPath: filepath.Join(ROOT_PATH, "config", "dkim", "dkim.priv"), + SSLType: SSLTypeUser, + SpamFilterLevel: runtime.Email.Policy.SpamFilterLevel, + LoginPolicy: runtime.Email.Policy.LoginVerificationPolicy, + ExternalRecoveryEnabled: runtime.Email.Policy.ExternalRecoveryEnabled, + SMTPHost: runtime.SMTP.Relay.RelayHost, SMTPPort: runtime.SMTP.Relay.RelayPort, + SMTPUsername: runtime.SMTP.Relay.Username, SMTPSecurity: runtime.SMTP.Relay.Security, + SMTPFrom: runtime.SMTP.Relay.From, SMTPFromDomain: runtime.SMTP.Relay.FromDomain, + VerificationEnabled: runtime.SMTP.Relay.VerificationEnabled, + } + if root.HTTP.HTTPSEnabled { + cfg.HttpsEnabled = 1 + } + if runtime.SMTP.Relay.Password.Configured() { + cfg.SMTPPassword, err = OpenStoredSecret(vault, runtime.SMTP.Relay.Password) + if err != nil { + return nil, err + } + } + tls := runtime.SSL.TLS + if tls.Mode == ManagedSSLModeDefault { + cfg.SSLType = SSLTypeAutoHTTP + } + // 迁移器保留的旧证书引用仍走严格的只读文件校验;新托管引用沿用托管解析器。 + if strings.HasPrefix(tls.CertificateRef, managedSSLRelativeRoot+"/") { + cfg.SSL = tls + } else { + cfg.SSLPublicKeyPath = resolvePath(tls.CertificateRef) + cfg.SSLPrivateKeyPath = resolvePath(tls.PrivateKeyRef) + } + return cfg, nil +} + +func compatibilityDatabase(database DatabaseConfig, vault *credential.Vault) (string, string, error) { + if database.Type == DatabaseTypeSQLite { + return DBTypeSQLite, filepath.Join(ROOT_PATH, "config", database.LocalRef), nil + } + password, err := OpenStoredSecret(vault, database.Password) + if err != nil { + return "", "", err + } + address := net.JoinHostPort(database.Host, strconv.Itoa(int(database.Port))) + switch database.Type { + case DatabaseTypeMySQL: + settings := mysql.NewConfig() + settings.User, settings.Passwd = database.Username, password + settings.Net, settings.Addr, settings.DBName = "tcp", address, database.Name + settings.ParseTime = true + return DBTypeMySQL, settings.FormatDSN(), nil + case DatabaseTypePostgreSQL: + uri := url.URL{Scheme: "postgres", User: url.UserPassword(database.Username, password), Host: address, Path: "/" + database.Name} + return DBTypePostgres, uri.String(), nil + default: + return "", "", fmt.Errorf("不支持的数据库类型") + } +} diff --git a/backend/config/partitioned_compatibility_test.go b/backend/config/partitioned_compatibility_test.go new file mode 100644 index 00000000..00e4f59c --- /dev/null +++ b/backend/config/partitioned_compatibility_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestPartitionedCompatibilityStartupAndWriteProtection(t *testing.T) { + root := t.TempDir() + oldRoot, oldArgs, oldDevelopment := ROOT_PATH, os.Args, DevelopmentMode + oldInstance, oldSnapshot := Instance, CurrentConfig() + ROOT_PATH, os.Args = root, []string{"smail"} + t.Cleanup(func() { + ROOT_PATH, os.Args, DevelopmentMode = oldRoot, oldArgs, oldDevelopment + Instance = oldInstance + PublishConfigSnapshot(oldSnapshot) + }) + writeLegacyMigrationFixture(t, root, map[string]any{ + "logLevel": "info", "domain": "mail.example.com", "webDomain": "mail.example.com", + "dbType": "sqlite", "dbDSN": "./config/existing.db", "isInit": true, + "sslType": "1", "SSLPublicKeyPath": "./config/ssl/site.crt", "SSLPrivateKeyPath": "./config/ssl/site.key", + "httpPort": 80, "httpsPort": 443, "httpsEnabled": 1, + }) + if _, err := MigrateLegacyConfig(root, RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "config", "config.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := Init(); err != nil { + t.Fatal(err) + } + cfg := CurrentConfig() + if !cfg.IsSetupReady() || cfg.DbType != DBTypeSQLite || cfg.DbDSN != filepath.Join(root, "config", "existing.db") { + t.Fatal("partitioned startup lost initialization or database identity") + } + if cfg.WebDomain != "mail.example.com" || cfg.SSLPublicKeyPath != filepath.Join(root, "config", "ssl", "site.crt") { + t.Fatal("partitioned startup lost domain or certificate path") + } + } + cfg, err := ReadConfig() + if err != nil { + t.Fatal(err) + } + if err := WriteConfig(cfg); err == nil { + t.Fatal("legacy writer must not overwrite partitioned config") + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatal("loading or legacy writing changed partitioned root") + } + if err := os.WriteFile(filepath.Join(root, "config", "smtp.json"), []byte("{}"), 0600); err != nil { + t.Fatal(err) + } + if err := Init(); err == nil { + t.Fatal("corrupted partition must not silently fall back to legacy startup") + } +} diff --git a/server/config/runtime_access.go b/backend/config/runtime_access.go similarity index 100% rename from server/config/runtime_access.go rename to backend/config/runtime_access.go diff --git a/server/config/runtime_access_port_test.go b/backend/config/runtime_access_port_test.go similarity index 100% rename from server/config/runtime_access_port_test.go rename to backend/config/runtime_access_port_test.go diff --git a/server/config/runtime_access_test.go b/backend/config/runtime_access_test.go similarity index 100% rename from server/config/runtime_access_test.go rename to backend/config/runtime_access_test.go diff --git a/server/config/runtime_manager.go b/backend/config/runtime_manager.go similarity index 100% rename from server/config/runtime_manager.go rename to backend/config/runtime_manager.go diff --git a/server/config/runtime_validator.go b/backend/config/runtime_validator.go similarity index 100% rename from server/config/runtime_validator.go rename to backend/config/runtime_validator.go diff --git a/server/config/runtime_validator_test.go b/backend/config/runtime_validator_test.go similarity index 100% rename from server/config/runtime_validator_test.go rename to backend/config/runtime_validator_test.go diff --git a/server/config/secret.go b/backend/config/secret.go similarity index 100% rename from server/config/secret.go rename to backend/config/secret.go diff --git a/server/config/secret_test.go b/backend/config/secret_test.go similarity index 100% rename from server/config/secret_test.go rename to backend/config/secret_test.go diff --git a/server/config/sections.go b/backend/config/sections.go similarity index 100% rename from server/config/sections.go rename to backend/config/sections.go diff --git a/server/config/sections_test.go b/backend/config/sections_test.go similarity index 100% rename from server/config/sections_test.go rename to backend/config/sections_test.go diff --git a/server/config/security.go b/backend/config/security.go similarity index 100% rename from server/config/security.go rename to backend/config/security.go diff --git a/server/config/sensitive.go b/backend/config/sensitive.go similarity index 100% rename from server/config/sensitive.go rename to backend/config/sensitive.go diff --git a/server/config/sensitive_test.go b/backend/config/sensitive_test.go similarity index 100% rename from server/config/sensitive_test.go rename to backend/config/sensitive_test.go diff --git a/server/config/snapshot.go b/backend/config/snapshot.go similarity index 100% rename from server/config/snapshot.go rename to backend/config/snapshot.go diff --git a/server/config/snapshot_test.go b/backend/config/snapshot_test.go similarity index 100% rename from server/config/snapshot_test.go rename to backend/config/snapshot_test.go diff --git a/server/config/ssl/README.md b/backend/config/ssl/README.md similarity index 100% rename from server/config/ssl/README.md rename to backend/config/ssl/README.md diff --git a/server/config/ssl_storage.go b/backend/config/ssl_storage.go similarity index 100% rename from server/config/ssl_storage.go rename to backend/config/ssl_storage.go diff --git a/server/config/ssl_storage_links_unix.go b/backend/config/ssl_storage_links_unix.go similarity index 100% rename from server/config/ssl_storage_links_unix.go rename to backend/config/ssl_storage_links_unix.go diff --git a/server/config/ssl_storage_links_windows.go b/backend/config/ssl_storage_links_windows.go similarity index 100% rename from server/config/ssl_storage_links_windows.go rename to backend/config/ssl_storage_links_windows.go diff --git a/server/config/ssl_storage_test.go b/backend/config/ssl_storage_test.go similarity index 100% rename from server/config/ssl_storage_test.go rename to backend/config/ssl_storage_test.go diff --git a/server/config/storage.go b/backend/config/storage.go similarity index 100% rename from server/config/storage.go rename to backend/config/storage.go diff --git a/server/config/storage_test.go b/backend/config/storage_test.go similarity index 100% rename from server/config/storage_test.go rename to backend/config/storage_test.go diff --git a/server/config/transaction.go b/backend/config/transaction.go similarity index 100% rename from server/config/transaction.go rename to backend/config/transaction.go diff --git a/server/config/validator.go b/backend/config/validator.go similarity index 100% rename from server/config/validator.go rename to backend/config/validator.go diff --git a/server/config/validator_security_test.go b/backend/config/validator_security_test.go similarity index 100% rename from server/config/validator_security_test.go rename to backend/config/validator_security_test.go diff --git a/server/consts/consts.go b/backend/consts/consts.go similarity index 100% rename from server/consts/consts.go rename to backend/consts/consts.go diff --git a/server/controllers/attachments.go b/backend/controllers/attachments.go similarity index 100% rename from server/controllers/attachments.go rename to backend/controllers/attachments.go diff --git a/server/controllers/attachments_test.go b/backend/controllers/attachments_test.go similarity index 100% rename from server/controllers/attachments_test.go rename to backend/controllers/attachments_test.go diff --git a/server/controllers/authorization.go b/backend/controllers/authorization.go similarity index 100% rename from server/controllers/authorization.go rename to backend/controllers/authorization.go diff --git a/server/controllers/base.go b/backend/controllers/base.go similarity index 100% rename from server/controllers/base.go rename to backend/controllers/base.go diff --git a/server/controllers/config.go b/backend/controllers/config.go similarity index 96% rename from server/controllers/config.go rename to backend/controllers/config.go index 51a66656..cec33325 100644 --- a/server/controllers/config.go +++ b/backend/controllers/config.go @@ -17,16 +17,21 @@ import ( // ConfigStatus 返回配置管理状态,不返回任何密文、路径或 DSN。 func ConfigStatus(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) { - manager, ok := configManagerOrError(w) - if !ok { - return - } if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { writeConfigAccessError(w, err) return } + manager := config.CurrentManager() + if manager == nil { + response.NewSuccessResponse(map[string]any{ + "available": false, + "unavailableReason": "当前使用旧版配置,在线配置管理尚未启用。请由部署管理员完成分区配置迁移后重启服务。", + }).FPrint(w) + return + } status := manager.Status() response.NewSuccessResponse(map[string]any{ + "available": true, "setupState": status.SetupState, "activeGeneration": status.ActiveGeneration, "lastValidAt": status.LastValidAt, @@ -151,7 +156,8 @@ func ListConfigBackups(ctx *appcontext.Context, w http.ResponseWriter, req *http if !ok { return } - if err := authorizeCapability(ctx, access.CapabilityConfigRollback, "", false); err != nil { + // 读取脱敏代次不执行回滚;预览、票据和执行仍要求高风险授权。 + if err := authorizeCapability(ctx, access.CapabilityConfigRead, "", false); err != nil { writeConfigAccessError(w, err) return } @@ -330,7 +336,7 @@ func configRequestManager(w http.ResponseWriter, req *http.Request) (*config.Con func configManagerOrError(w http.ResponseWriter) (*config.ConfigManager, bool) { manager := config.CurrentManager() if manager == nil { - writeConfigError(w, errors.New("configuration manager unavailable")) + writeConfigError(w, config.ErrManagerStorage) return nil, false } return manager, true diff --git a/server/controllers/config_audit.go b/backend/controllers/config_audit.go similarity index 100% rename from server/controllers/config_audit.go rename to backend/controllers/config_audit.go diff --git a/server/controllers/config_audit_query_test.go b/backend/controllers/config_audit_query_test.go similarity index 100% rename from server/controllers/config_audit_query_test.go rename to backend/controllers/config_audit_query_test.go diff --git a/backend/controllers/config_backup_access_test.go b/backend/controllers/config_backup_access_test.go new file mode 100644 index 00000000..66bc901e --- /dev/null +++ b/backend/controllers/config_backup_access_test.go @@ -0,0 +1,76 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/services/access" + appcontext "git.pcux.cn/xuans/Smail/utils/context" +) + +func TestConfigBackupReadDoesNotRequireRecentAuth(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil { + t.Fatal(err) + } + legacy := []byte(`{"domain":"mail.example.com","webDomain":"mail.example.com","dbType":"sqlite","dbDSN":"./config/test.db","isInit":true,"httpPort":80,"httpsPort":443,"httpsEnabled":1}`) + if err := os.WriteFile(filepath.Join(root, "config", "config.json"), legacy, 0600); err != nil { + t.Fatal(err) + } + validation := config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true} + if _, err := config.MigrateLegacyConfig(root, validation); err != nil { + t.Fatal(err) + } + manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{Validation: validation}) + if err != nil { + t.Fatal(err) + } + previous := config.CurrentManager() + config.SetRuntimeManager(manager) + t.Cleanup(func() { config.SetRuntimeManager(previous) }) + + for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} { + t.Run(string(role), func(t *testing.T) { + ctx := &appcontext.Context{UserID: 1, Role: role, RecentAuth: false} + recorder := httptest.NewRecorder() + ListConfigBackups(ctx, recorder, httptest.NewRequest(http.MethodGet, "/api/v1/config/backups", nil)) + if role == access.RoleUser { + if recorder.Code != http.StatusForbidden { + t.Fatalf("普通用户不应读取配置备份:%d", recorder.Code) + } + return + } + var payload struct { + ErrorNo int `json:"errorNo"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if recorder.Code != http.StatusOK || payload.ErrorNo != 0 { + t.Fatalf("管理员读取脱敏备份不应要求近期认证:%d", recorder.Code) + } + }) + } + + // 列表读取放宽不能改变实际回滚链路的近期认证要求。 + for name, handler := range map[string]func(*appcontext.Context, http.ResponseWriter, *http.Request){ + "preview": PreviewConfigRollback, + "ticket": IssueConfigRollbackTicket, + "execute": RollbackConfig, + } { + t.Run(name, func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(&appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin}, recorder, + httptest.NewRequest(http.MethodPost, "/api/v1/config/rollback", strings.NewReader(`{"targetGeneration":1}`))) + if recorder.Code != http.StatusForbidden || !strings.Contains(recorder.Body.String(), "RECENT_AUTH_REQUIRED") { + t.Fatalf("回滚仍应要求近期认证:%d", recorder.Code) + } + }) + } +} diff --git a/server/controllers/config_error.go b/backend/controllers/config_error.go similarity index 100% rename from server/controllers/config_error.go rename to backend/controllers/config_error.go diff --git a/server/controllers/config_error_test.go b/backend/controllers/config_error_test.go similarity index 100% rename from server/controllers/config_error_test.go rename to backend/controllers/config_error_test.go diff --git a/server/controllers/config_public_response_test.go b/backend/controllers/config_public_response_test.go similarity index 100% rename from server/controllers/config_public_response_test.go rename to backend/controllers/config_public_response_test.go diff --git a/server/controllers/config_public_settings_test.go b/backend/controllers/config_public_settings_test.go similarity index 100% rename from server/controllers/config_public_settings_test.go rename to backend/controllers/config_public_settings_test.go diff --git a/server/controllers/database_migration.go b/backend/controllers/database_migration.go similarity index 100% rename from server/controllers/database_migration.go rename to backend/controllers/database_migration.go diff --git a/server/controllers/email/delete.go b/backend/controllers/email/delete.go similarity index 100% rename from server/controllers/email/delete.go rename to backend/controllers/email/delete.go diff --git a/server/controllers/email/detail.go b/backend/controllers/email/detail.go similarity index 100% rename from server/controllers/email/detail.go rename to backend/controllers/email/detail.go diff --git a/server/controllers/email/list.go b/backend/controllers/email/list.go similarity index 100% rename from server/controllers/email/list.go rename to backend/controllers/email/list.go diff --git a/server/controllers/email/list_test.go b/backend/controllers/email/list_test.go similarity index 100% rename from server/controllers/email/list_test.go rename to backend/controllers/email/list_test.go diff --git a/server/controllers/email/move.go b/backend/controllers/email/move.go similarity index 100% rename from server/controllers/email/move.go rename to backend/controllers/email/move.go diff --git a/server/controllers/email/read.go b/backend/controllers/email/read.go similarity index 100% rename from server/controllers/email/read.go rename to backend/controllers/email/read.go diff --git a/server/controllers/email/request.go b/backend/controllers/email/request.go similarity index 100% rename from server/controllers/email/request.go rename to backend/controllers/email/request.go diff --git a/server/controllers/email/request_security_test.go b/backend/controllers/email/request_security_test.go similarity index 100% rename from server/controllers/email/request_security_test.go rename to backend/controllers/email/request_security_test.go diff --git a/server/controllers/email/send.go b/backend/controllers/email/send.go similarity index 100% rename from server/controllers/email/send.go rename to backend/controllers/email/send.go diff --git a/server/controllers/email/send_test.go b/backend/controllers/email/send_test.go similarity index 100% rename from server/controllers/email/send_test.go rename to backend/controllers/email/send_test.go diff --git a/server/controllers/group.go b/backend/controllers/group.go similarity index 100% rename from server/controllers/group.go rename to backend/controllers/group.go diff --git a/server/controllers/interceptor.go b/backend/controllers/interceptor.go similarity index 100% rename from server/controllers/interceptor.go rename to backend/controllers/interceptor.go diff --git a/server/controllers/login.go b/backend/controllers/login.go similarity index 96% rename from server/controllers/login.go rename to backend/controllers/login.go index eff328d8..72c8b994 100644 --- a/server/controllers/login.go +++ b/backend/controllers/login.go @@ -99,11 +99,13 @@ func hashedLoginKey(parts ...string) string { return hex.EncodeToString(hash.Sum(nil)) } -// loginSecurityFields returns the non-sensitive state used by the post-login security guide. +// loginSecurityFields 返回登录后安全引导所需的非敏感状态。 func loginSecurityFields(user *models.User) map[string]any { fields := map[string]any{ "recoveryCodesConfigured": false, "showRecoverySetupPrompt": false, + "showSecuritySetupPrompt": user != nil && user.SecuritySetupAcknowledged == 0, + "webAuthnEnabled": webAuthnRuntime() != nil, } if user == nil || user.EffectiveRole() == access.RoleSuperAdmin { return fields @@ -272,11 +274,11 @@ func Login(ctx *context.Context, w http.ResponseWriter, req *http.Request) { } func Logout(ctx *context.Context, w http.ResponseWriter, req *http.Request) { - if err := session.Instance.Clear(ctx.Context); err != nil { + csrf.Clear(w, req, !config.DevelopmentMode) + if err := session.Instance.Destroy(ctx.Context); err != nil { log.WithContext(ctx).WithError(err).Error("session cleanup during logout failed") response.NewErrorResponse(response.ServerError, "退出失败", "").FPrint(w) return } - csrf.Clear(w, req, !config.DevelopmentMode) response.NewSuccessResponse("Success").FPrint(w) } diff --git a/server/controllers/login_security_test.go b/backend/controllers/login_security_test.go similarity index 100% rename from server/controllers/login_security_test.go rename to backend/controllers/login_security_test.go diff --git a/server/controllers/login_verification.go b/backend/controllers/login_verification.go similarity index 100% rename from server/controllers/login_verification.go rename to backend/controllers/login_verification.go diff --git a/server/controllers/login_verification_policy_test.go b/backend/controllers/login_verification_policy_test.go similarity index 100% rename from server/controllers/login_verification_policy_test.go rename to backend/controllers/login_verification_policy_test.go diff --git a/server/controllers/password_reset_permissions_test.go b/backend/controllers/password_reset_permissions_test.go similarity index 100% rename from server/controllers/password_reset_permissions_test.go rename to backend/controllers/password_reset_permissions_test.go diff --git a/server/controllers/ping.go b/backend/controllers/ping.go similarity index 100% rename from server/controllers/ping.go rename to backend/controllers/ping.go diff --git a/server/controllers/plugin.go b/backend/controllers/plugin.go similarity index 100% rename from server/controllers/plugin.go rename to backend/controllers/plugin.go diff --git a/server/controllers/plugin_test.go b/backend/controllers/plugin_test.go similarity index 100% rename from server/controllers/plugin_test.go rename to backend/controllers/plugin_test.go diff --git a/server/controllers/plugin_v1.go b/backend/controllers/plugin_v1.go similarity index 100% rename from server/controllers/plugin_v1.go rename to backend/controllers/plugin_v1.go diff --git a/server/controllers/plugin_v1_test.go b/backend/controllers/plugin_v1_test.go similarity index 100% rename from server/controllers/plugin_v1_test.go rename to backend/controllers/plugin_v1_test.go diff --git a/server/controllers/request.go b/backend/controllers/request.go similarity index 100% rename from server/controllers/request.go rename to backend/controllers/request.go diff --git a/server/controllers/request_security_test.go b/backend/controllers/request_security_test.go similarity index 100% rename from server/controllers/request_security_test.go rename to backend/controllers/request_security_test.go diff --git a/server/controllers/restart.go b/backend/controllers/restart.go similarity index 100% rename from server/controllers/restart.go rename to backend/controllers/restart.go diff --git a/server/controllers/rule.go b/backend/controllers/rule.go similarity index 100% rename from server/controllers/rule.go rename to backend/controllers/rule.go diff --git a/server/controllers/rule_security_test.go b/backend/controllers/rule_security_test.go similarity index 100% rename from server/controllers/rule_security_test.go rename to backend/controllers/rule_security_test.go diff --git a/server/controllers/security.go b/backend/controllers/security.go similarity index 100% rename from server/controllers/security.go rename to backend/controllers/security.go diff --git a/server/controllers/security_authentication_test.go b/backend/controllers/security_authentication_test.go similarity index 100% rename from server/controllers/security_authentication_test.go rename to backend/controllers/security_authentication_test.go diff --git a/server/controllers/security_limiter_test.go b/backend/controllers/security_limiter_test.go similarity index 100% rename from server/controllers/security_limiter_test.go rename to backend/controllers/security_limiter_test.go diff --git a/server/controllers/security_public_response_test.go b/backend/controllers/security_public_response_test.go similarity index 100% rename from server/controllers/security_public_response_test.go rename to backend/controllers/security_public_response_test.go diff --git a/server/controllers/security_remote_ip_test.go b/backend/controllers/security_remote_ip_test.go similarity index 100% rename from server/controllers/security_remote_ip_test.go rename to backend/controllers/security_remote_ip_test.go diff --git a/server/controllers/security_reset_entry_test.go b/backend/controllers/security_reset_entry_test.go similarity index 100% rename from server/controllers/security_reset_entry_test.go rename to backend/controllers/security_reset_entry_test.go diff --git a/backend/controllers/security_setup.go b/backend/controllers/security_setup.go new file mode 100644 index 00000000..488ccf22 --- /dev/null +++ b/backend/controllers/security_setup.go @@ -0,0 +1,36 @@ +package controllers + +import ( + "net/http" + + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/utils/context" +) + +// AcknowledgeSecuritySetup 仅确认当前账号已经查看引导,不代表完成安全设置。 +func AcknowledgeSecuritySetup(ctx *context.Context, w http.ResponseWriter, req *http.Request) { + if ctx == nil || ctx.UserID <= 0 { + writeSecurityError(w, response.NeedLogin, "请先登录") + return + } + var user models.User + found, err := db.Instance.ID(ctx.UserID).Get(&user) + if err != nil { + writeSecurityError(w, response.ServerError, "读取安全引导状态失败,请重试") + return + } + if !found || user.Disabled != 0 { + writeSecurityError(w, response.NeedLogin, "请重新登录") + return + } + if user.SecuritySetupAcknowledged == 0 { + user.SecuritySetupAcknowledged = 1 + if _, err := db.Instance.ID(ctx.UserID).Cols("security_setup_acknowledged").Update(&user); err != nil { + writeSecurityError(w, response.ServerError, "保存安全引导状态失败,请重试") + return + } + } + response.NewSuccessResponse(map[string]any{"showSecuritySetupPrompt": false}).FPrint(w) +} diff --git a/backend/controllers/security_setup_test.go b/backend/controllers/security_setup_test.go new file mode 100644 index 00000000..01ea7af6 --- /dev/null +++ b/backend/controllers/security_setup_test.go @@ -0,0 +1,138 @@ +package controllers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/session" + appcontext "git.pcux.cn/xuans/Smail/utils/context" + "git.pcux.cn/xuans/Smail/utils/csrf" + "github.com/alexedwards/scs/v2" + "xorm.io/xorm" +) + +func TestLogoutDestroysSessionAndCSRFCookies(t *testing.T) { + previous := session.Instance + session.Instance = scs.New() + t.Cleanup(func() { session.Instance = previous }) + request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/v1/logout", nil) + loaded, err := session.Instance.Load(request.Context(), "") + if err != nil { + t.Fatal(err) + } + session.Instance.Put(loaded, csrf.SessionKey, "test-csrf") + token, _, err := session.Instance.Commit(loaded) + if err != nil { + t.Fatal(err) + } + request.AddCookie(&http.Cookie{Name: session.Instance.Cookie.Name, Value: token}) + recorder := httptest.NewRecorder() + session.Instance.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Logout(&appcontext.Context{Context: r.Context(), UserID: 1}, w, r) + })).ServeHTTP(recorder, request) + removed := map[string]bool{} + for _, cookie := range recorder.Result().Cookies() { + removed[cookie.Name] = cookie.MaxAge < 0 && cookie.Value == "" + } + if !removed[session.Instance.Cookie.Name] || !removed[csrf.CookieName] { + t.Fatal("logout must expire both session and CSRF cookies") + } + _, exists, err := session.Instance.Store.Find(token) + if err != nil || exists { + t.Fatal("logout left a valid stored session") + } +} + +func TestSecuritySetupAcknowledgementIsPersistentAndAccountScoped(t *testing.T) { + engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "onboarding.db")) + if err != nil { + t.Fatal(err) + } + previous := db.Instance + db.Instance = engine + t.Cleanup(func() { db.Instance = previous; _ = engine.Close() }) + if err := engine.Sync2(&models.User{}); err != nil { + t.Fatal(err) + } + first := models.User{Account: "root", Name: "Root", Role: string(access.RoleSuperAdmin)} + second := models.User{Account: "alice", Name: "Alice", Role: string(access.RoleUser)} + if _, err := engine.Insert(&first, &second); err != nil { + t.Fatal(err) + } + if loginSecurityFields(&first)["showSecuritySetupPrompt"] != true { + t.Fatal("first super administrator login must show security setup") + } + for i := 0; i < 2; i++ { + recorder := httptest.NewRecorder() + AcknowledgeSecuritySetup(&appcontext.Context{UserID: first.ID}, recorder, + httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", strings.NewReader(`{"user_id":2}`))) + var payload struct { + ErrorNo int `json:"errorNo"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo != 0 { + t.Fatalf("acknowledgement failed: %s", recorder.Body.String()) + } + } + var loaded, untouched models.User + if _, err := engine.ID(first.ID).Get(&loaded); err != nil { + t.Fatal(err) + } + if _, err := engine.ID(second.ID).Get(&untouched); err != nil { + t.Fatal(err) + } + if loaded.SecuritySetupAcknowledged != 1 || untouched.SecuritySetupAcknowledged != 0 { + t.Fatal("acknowledgement changed the wrong account") + } + fields := loginSecurityFields(&loaded) + if fields["showSecuritySetupPrompt"] != false || fields["showRecoverySetupPrompt"] != false { + t.Fatal("acknowledged super administrator must not receive either prompt") + } + if untouched.Password != second.Password || loaded.SecurityVersion != first.SecurityVersion { + t.Fatal("acknowledgement changed security credentials") + } +} + +func TestSecuritySetupRejectsAnonymous(t *testing.T) { + recorder := httptest.NewRecorder() + AcknowledgeSecuritySetup(nil, recorder, httptest.NewRequest(http.MethodPost, "/api/v1/security/setup/acknowledge", nil)) + var payload struct { + ErrorNo int `json:"errorNo"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload.ErrorNo == 0 { + t.Fatal("anonymous acknowledgement was accepted") + } +} + +func TestLegacyConfigStatusReportsUnavailable(t *testing.T) { + previous := config.CurrentManager() + config.SetRuntimeManager(nil) + t.Cleanup(func() { config.SetRuntimeManager(previous) }) + recorder := httptest.NewRecorder() + ConfigStatus(&appcontext.Context{UserID: 1, Role: access.RoleSuperAdmin}, recorder, + httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil)) + var payload struct { + ErrorNo int `json:"errorNo"` + Data struct { + Available bool `json:"available"` + Reason string `json:"unavailableReason"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if recorder.Code != http.StatusOK || payload.ErrorNo != 0 || payload.Data.Available || payload.Data.Reason == "" { + t.Fatalf("unexpected config status: %s", recorder.Body.String()) + } + recorder = httptest.NewRecorder() + if _, ok := configManagerOrError(recorder); ok || recorder.Code != http.StatusServiceUnavailable { + t.Fatal("unavailable config operation must return HTTP 503") + } +} diff --git a/server/controllers/settings.go b/backend/controllers/settings.go similarity index 100% rename from server/controllers/settings.go rename to backend/controllers/settings.go diff --git a/server/controllers/settings_test.go b/backend/controllers/settings_test.go similarity index 100% rename from server/controllers/settings_test.go rename to backend/controllers/settings_test.go diff --git a/server/controllers/setup.go b/backend/controllers/setup.go similarity index 100% rename from server/controllers/setup.go rename to backend/controllers/setup.go diff --git a/server/controllers/setup_contract_test.go b/backend/controllers/setup_contract_test.go similarity index 100% rename from server/controllers/setup_contract_test.go rename to backend/controllers/setup_contract_test.go diff --git a/server/controllers/user.go b/backend/controllers/user.go similarity index 99% rename from server/controllers/user.go rename to backend/controllers/user.go index bb6fae52..f0ea1c72 100644 --- a/server/controllers/user.go +++ b/backend/controllers/user.go @@ -153,6 +153,8 @@ func Info(ctx *context.Context, w http.ResponseWriter, req *http.Request) { "capabilities": access.Capabilities(ctx.EffectiveRole()), "is_admin": ctx.IsAdmin, "must_change_password": ctx.MustChangePassword, + "showSecuritySetupPrompt": ctx.SecuritySetupAcknowledged == 0, + "webAuthnEnabled": webAuthnRuntime() != nil, "domains": domains, } if ctx.EffectiveRole() != access.RoleSuperAdmin { diff --git a/server/controllers/user_security_test.go b/backend/controllers/user_security_test.go similarity index 100% rename from server/controllers/user_security_test.go rename to backend/controllers/user_security_test.go diff --git a/server/controllers/webauthn.go b/backend/controllers/webauthn.go similarity index 100% rename from server/controllers/webauthn.go rename to backend/controllers/webauthn.go diff --git a/server/db/init.go b/backend/db/init.go similarity index 100% rename from server/db/init.go rename to backend/db/init.go diff --git a/server/db/init_migration_test.go b/backend/db/init_migration_test.go similarity index 100% rename from server/db/init_migration_test.go rename to backend/db/init_migration_test.go diff --git a/server/db/migration/dialect.go b/backend/db/migration/dialect.go similarity index 100% rename from server/db/migration/dialect.go rename to backend/db/migration/dialect.go diff --git a/server/db/migration/dialect_test.go b/backend/db/migration/dialect_test.go similarity index 100% rename from server/db/migration/dialect_test.go rename to backend/db/migration/dialect_test.go diff --git a/server/db/migration/imap_flags.go b/backend/db/migration/imap_flags.go similarity index 100% rename from server/db/migration/imap_flags.go rename to backend/db/migration/imap_flags.go diff --git a/server/db/migration/index.go b/backend/db/migration/index.go similarity index 100% rename from server/db/migration/index.go rename to backend/db/migration/index.go diff --git a/server/db/migration/integration_test.go b/backend/db/migration/integration_test.go similarity index 100% rename from server/db/migration/integration_test.go rename to backend/db/migration/integration_test.go diff --git a/server/db/migration/migration.go b/backend/db/migration/migration.go similarity index 100% rename from server/db/migration/migration.go rename to backend/db/migration/migration.go diff --git a/server/db/migration/protocol_credentials.go b/backend/db/migration/protocol_credentials.go similarity index 100% rename from server/db/migration/protocol_credentials.go rename to backend/db/migration/protocol_credentials.go diff --git a/server/db/migration/registry.go b/backend/db/migration/registry.go similarity index 94% rename from server/db/migration/registry.go rename to backend/db/migration/registry.go index 8fa919b2..f6857c01 100644 --- a/server/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -19,5 +19,6 @@ func DefaultMigrations() []Migration { DefineCustom(12, "expand_user_email_login_addresses", "expand_user_email_login_addresses_v1", applyUserEmailAddressSchema), DefineForDialects(13, "create_protocol_credentials", protocolCredentialStatements()), DefineCustom(14, "expand_imap_deleted_flag", "expand_imap_deleted_flag_v1", applyIMAPDeletedFlagSchema), + DefineCustom(15, "expand_security_setup", "expand_security_setup_v1", applySecuritySetupSchema), } } diff --git a/server/db/migration/registry_test.go b/backend/db/migration/registry_test.go similarity index 98% rename from server/db/migration/registry_test.go rename to backend/db/migration/registry_test.go index 713942e6..bf17a07e 100644 --- a/server/db/migration/registry_test.go +++ b/backend/db/migration/registry_test.go @@ -25,7 +25,7 @@ func TestDefaultMigrationsCreateCompleteSQLiteSchema(t *testing.T) { t.Fatalf("table %s was not created", table) } } - for _, column := range []string{"role", "session_version", "security_version", "must_change_password", "password_changed_at"} { + for _, column := range []string{"role", "session_version", "security_version", "must_change_password", "password_changed_at", "security_setup_acknowledged"} { exists, err := columnExists(context.Background(), db, DialectSQLite, "user", column) if err != nil { t.Fatal(err) @@ -216,6 +216,7 @@ func TestDefaultMigrationChecksumsAreImmutable(t *testing.T) { 12: "b746b89a15262ecedacf8725148d77be22caa57e4dfb60a209854ecdec98382b", 13: "c51604f27347d0a1b93acad1e33af53463fceb7b6573960f881f95f29c5628ed", 14: "531398c2b8fc83a65753fae08666946bb253ac3da6596829e71cc8ccc4ddf62f", + 15: "59f0ff9acfed3619c17f078d21a1362ddd5f1feacb9a2d77626f69040d25de65", } migrations := DefaultMigrations() if len(migrations) != len(expected) { diff --git a/server/db/migration/runner.go b/backend/db/migration/runner.go similarity index 100% rename from server/db/migration/runner.go rename to backend/db/migration/runner.go diff --git a/server/db/migration/runner_test.go b/backend/db/migration/runner_test.go similarity index 100% rename from server/db/migration/runner_test.go rename to backend/db/migration/runner_test.go diff --git a/server/db/migration/schema.go b/backend/db/migration/schema.go similarity index 100% rename from server/db/migration/schema.go rename to backend/db/migration/schema.go diff --git a/backend/db/migration/security_setup.go b/backend/db/migration/security_setup.go new file mode 100644 index 00000000..6cd9f191 --- /dev/null +++ b/backend/db/migration/security_setup.go @@ -0,0 +1,19 @@ +package migration + +import ( + "context" + "fmt" +) + +func applySecuritySetupSchema(ctx context.Context, exec executor, dialect Dialect) error { + definitions := map[Dialect]string{ + DialectSQLite: "INTEGER NOT NULL DEFAULT 0", + DialectMySQL: "TINYINT NOT NULL DEFAULT 0", + DialectPostgres: "SMALLINT NOT NULL DEFAULT 0", + } + definition, ok := definitions[dialect] + if !ok { + return fmt.Errorf("unsupported migration dialect %q", dialect) + } + return ensureColumn(ctx, exec, dialect, "user", "security_setup_acknowledged", definition) +} diff --git a/backend/db/migration/security_setup_test.go b/backend/db/migration/security_setup_test.go new file mode 100644 index 00000000..9c4172ec --- /dev/null +++ b/backend/db/migration/security_setup_test.go @@ -0,0 +1,40 @@ +package migration + +import ( + "context" + "testing" +) + +func TestSecuritySetupMigrationPreservesAcknowledgement(t *testing.T) { + db := openSQLite(t) + runner := Runner{DB: db, Dialect: DialectSQLite, Migrations: DefaultMigrations()[:14]} + if _, err := runner.Run(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO "user" (account, name, password) VALUES ('root', 'Root', 'unchanged')`); err != nil { + t.Fatal(err) + } + runner.Migrations = DefaultMigrations() + if _, err := runner.Run(context.Background()); err != nil { + t.Fatal(err) + } + var acknowledged int + if err := db.QueryRow(`SELECT security_setup_acknowledged FROM "user" WHERE account='root'`).Scan(&acknowledged); err != nil { + t.Fatal(err) + } + if acknowledged != 0 { + t.Fatal("existing account must receive the new guide once") + } + if _, err := db.Exec(`UPDATE "user" SET security_setup_acknowledged=1 WHERE account='root'`); err != nil { + t.Fatal(err) + } + if err := applySecuritySetupSchema(context.Background(), db, DialectSQLite); err != nil { + t.Fatal(err) + } + if err := db.QueryRow(`SELECT security_setup_acknowledged FROM "user" WHERE account='root'`).Scan(&acknowledged); err != nil { + t.Fatal(err) + } + if acknowledged != 1 { + t.Fatal("repeated migration reset acknowledgement") + } +} diff --git a/server/db/migration/totp.go b/backend/db/migration/totp.go similarity index 100% rename from server/db/migration/totp.go rename to backend/db/migration/totp.go diff --git a/server/db/migration/user_email.go b/backend/db/migration/user_email.go similarity index 100% rename from server/db/migration/user_email.go rename to backend/db/migration/user_email.go diff --git a/server/db/migration/verification_retention.go b/backend/db/migration/verification_retention.go similarity index 100% rename from server/db/migration/verification_retention.go rename to backend/db/migration/verification_retention.go diff --git a/server/db/migration/webauthn_discoverability.go b/backend/db/migration/webauthn_discoverability.go similarity index 100% rename from server/db/migration/webauthn_discoverability.go rename to backend/db/migration/webauthn_discoverability.go diff --git a/server/db/retry.go b/backend/db/retry.go similarity index 100% rename from server/db/retry.go rename to backend/db/retry.go diff --git a/server/db/retry_test.go b/backend/db/retry_test.go similarity index 100% rename from server/db/retry_test.go rename to backend/db/retry_test.go diff --git a/server/dto/parsemail/dkim.go b/backend/dto/parsemail/dkim.go similarity index 100% rename from server/dto/parsemail/dkim.go rename to backend/dto/parsemail/dkim.go diff --git a/server/dto/parsemail/dkim_security_test.go b/backend/dto/parsemail/dkim_security_test.go similarity index 100% rename from server/dto/parsemail/dkim_security_test.go rename to backend/dto/parsemail/dkim_security_test.go diff --git a/server/dto/parsemail/dkim_test.go b/backend/dto/parsemail/dkim_test.go similarity index 100% rename from server/dto/parsemail/dkim_test.go rename to backend/dto/parsemail/dkim_test.go diff --git a/server/dto/parsemail/email.go b/backend/dto/parsemail/email.go similarity index 100% rename from server/dto/parsemail/email.go rename to backend/dto/parsemail/email.go diff --git a/server/dto/parsemail/email_authentication_test.go b/backend/dto/parsemail/email_authentication_test.go similarity index 100% rename from server/dto/parsemail/email_authentication_test.go rename to backend/dto/parsemail/email_authentication_test.go diff --git a/server/dto/parsemail/email_build_security_test.go b/backend/dto/parsemail/email_build_security_test.go similarity index 100% rename from server/dto/parsemail/email_build_security_test.go rename to backend/dto/parsemail/email_build_security_test.go diff --git a/server/dto/parsemail/email_forward_test.go b/backend/dto/parsemail/email_forward_test.go similarity index 100% rename from server/dto/parsemail/email_forward_test.go rename to backend/dto/parsemail/email_forward_test.go diff --git a/server/dto/parsemail/email_storage_test.go b/backend/dto/parsemail/email_storage_test.go similarity index 100% rename from server/dto/parsemail/email_storage_test.go rename to backend/dto/parsemail/email_storage_test.go diff --git a/server/dto/parsemail/email_test.go b/backend/dto/parsemail/email_test.go similarity index 100% rename from server/dto/parsemail/email_test.go rename to backend/dto/parsemail/email_test.go diff --git a/server/dto/parsemail/email_xss_test.go b/backend/dto/parsemail/email_xss_test.go similarity index 100% rename from server/dto/parsemail/email_xss_test.go rename to backend/dto/parsemail/email_xss_test.go diff --git a/server/dto/parsemail/encodedword.go b/backend/dto/parsemail/encodedword.go similarity index 100% rename from server/dto/parsemail/encodedword.go rename to backend/dto/parsemail/encodedword.go diff --git a/server/dto/response/config.go b/backend/dto/response/config.go similarity index 100% rename from server/dto/response/config.go rename to backend/dto/response/config.go diff --git a/server/dto/response/email.go b/backend/dto/response/email.go similarity index 100% rename from server/dto/response/email.go rename to backend/dto/response/email.go diff --git a/server/dto/response/response.go b/backend/dto/response/response.go similarity index 100% rename from server/dto/response/response.go rename to backend/dto/response/response.go diff --git a/server/dto/rule.go b/backend/dto/rule.go similarity index 100% rename from server/dto/rule.go rename to backend/dto/rule.go diff --git a/server/dto/rule_test.go b/backend/dto/rule_test.go similarity index 100% rename from server/dto/rule_test.go rename to backend/dto/rule_test.go diff --git a/server/dto/tag.go b/backend/dto/tag.go similarity index 100% rename from server/dto/tag.go rename to backend/dto/tag.go diff --git a/server/go.mod b/backend/go.mod similarity index 100% rename from server/go.mod rename to backend/go.mod diff --git a/server/go.sum b/backend/go.sum similarity index 100% rename from server/go.sum rename to backend/go.sum diff --git a/server/hooks/base.go b/backend/hooks/base.go similarity index 100% rename from server/hooks/base.go rename to backend/hooks/base.go diff --git a/server/hooks/base_test.go b/backend/hooks/base_test.go similarity index 100% rename from server/hooks/base_test.go rename to backend/hooks/base_test.go diff --git a/server/hooks/debug/debug.go b/backend/hooks/debug/debug.go similarity index 100% rename from server/hooks/debug/debug.go rename to backend/hooks/debug/debug.go diff --git a/server/hooks/framework/framework.go b/backend/hooks/framework/framework.go similarity index 100% rename from server/hooks/framework/framework.go rename to backend/hooks/framework/framework.go diff --git a/server/hooks/framework/framework_security_test.go b/backend/hooks/framework/framework_security_test.go similarity index 100% rename from server/hooks/framework/framework_security_test.go rename to backend/hooks/framework/framework_security_test.go diff --git a/server/hooks/framework/framework_test.go b/backend/hooks/framework/framework_test.go similarity index 100% rename from server/hooks/framework/framework_test.go rename to backend/hooks/framework/framework_test.go diff --git a/server/hooks/plugin_isolation.go b/backend/hooks/plugin_isolation.go similarity index 100% rename from server/hooks/plugin_isolation.go rename to backend/hooks/plugin_isolation.go diff --git a/server/hooks/plugin_isolation_linux.go b/backend/hooks/plugin_isolation_linux.go similarity index 100% rename from server/hooks/plugin_isolation_linux.go rename to backend/hooks/plugin_isolation_linux.go diff --git a/server/hooks/plugin_isolation_linux_test.go b/backend/hooks/plugin_isolation_linux_test.go similarity index 100% rename from server/hooks/plugin_isolation_linux_test.go rename to backend/hooks/plugin_isolation_linux_test.go diff --git a/server/hooks/plugin_isolation_unsupported.go b/backend/hooks/plugin_isolation_unsupported.go similarity index 100% rename from server/hooks/plugin_isolation_unsupported.go rename to backend/hooks/plugin_isolation_unsupported.go diff --git a/server/hooks/plugin_isolation_windows.go b/backend/hooks/plugin_isolation_windows.go similarity index 100% rename from server/hooks/plugin_isolation_windows.go rename to backend/hooks/plugin_isolation_windows.go diff --git a/server/hooks/plugin_socket_test.go b/backend/hooks/plugin_socket_test.go similarity index 100% rename from server/hooks/plugin_socket_test.go rename to backend/hooks/plugin_socket_test.go diff --git a/server/hooks/registry.go b/backend/hooks/registry.go similarity index 100% rename from server/hooks/registry.go rename to backend/hooks/registry.go diff --git a/server/hooks/spam_block/README.md b/backend/hooks/spam_block/README.md similarity index 100% rename from server/hooks/spam_block/README.md rename to backend/hooks/spam_block/README.md diff --git a/server/hooks/spam_block/export/Makefile b/backend/hooks/spam_block/export/Makefile similarity index 100% rename from server/hooks/spam_block/export/Makefile rename to backend/hooks/spam_block/export/Makefile diff --git a/server/hooks/spam_block/export/export.go b/backend/hooks/spam_block/export/export.go similarity index 100% rename from server/hooks/spam_block/export/export.go rename to backend/hooks/spam_block/export/export.go diff --git a/server/hooks/spam_block/export/export_security_test.go b/backend/hooks/spam_block/export/export_security_test.go similarity index 100% rename from server/hooks/spam_block/export/export_security_test.go rename to backend/hooks/spam_block/export/export_security_test.go diff --git a/server/hooks/spam_block/requirements.txt b/backend/hooks/spam_block/requirements.txt similarity index 100% rename from server/hooks/spam_block/requirements.txt rename to backend/hooks/spam_block/requirements.txt diff --git a/server/hooks/spam_block/spam_block.go b/backend/hooks/spam_block/spam_block.go similarity index 100% rename from server/hooks/spam_block/spam_block.go rename to backend/hooks/spam_block/spam_block.go diff --git a/server/hooks/spam_block/spam_block_test.go b/backend/hooks/spam_block/spam_block_test.go similarity index 100% rename from server/hooks/spam_block/spam_block_test.go rename to backend/hooks/spam_block/spam_block_test.go diff --git a/server/hooks/spam_block/static/index.html b/backend/hooks/spam_block/static/index.html similarity index 100% rename from server/hooks/spam_block/static/index.html rename to backend/hooks/spam_block/static/index.html diff --git a/server/hooks/spam_block/static/jquery.js b/backend/hooks/spam_block/static/jquery.js similarity index 100% rename from server/hooks/spam_block/static/jquery.js rename to backend/hooks/spam_block/static/jquery.js diff --git a/server/hooks/spam_block/testData/data.csv b/backend/hooks/spam_block/testData/data.csv similarity index 100% rename from server/hooks/spam_block/testData/data.csv rename to backend/hooks/spam_block/testData/data.csv diff --git a/server/hooks/spam_block/tools/tools.go b/backend/hooks/spam_block/tools/tools.go similarity index 100% rename from server/hooks/spam_block/tools/tools.go rename to backend/hooks/spam_block/tools/tools.go diff --git a/server/hooks/spam_block/train.py b/backend/hooks/spam_block/train.py similarity index 100% rename from server/hooks/spam_block/train.py rename to backend/hooks/spam_block/train.py diff --git a/server/hooks/spam_block/trainData/data.csv b/backend/hooks/spam_block/trainData/data.csv similarity index 100% rename from server/hooks/spam_block/trainData/data.csv rename to backend/hooks/spam_block/trainData/data.csv diff --git a/server/hooks/spam_block/trec06c_format.py b/backend/hooks/spam_block/trec06c_format.py similarity index 100% rename from server/hooks/spam_block/trec06c_format.py rename to backend/hooks/spam_block/trec06c_format.py diff --git a/server/hooks/spam_block/trec07p_format.py b/backend/hooks/spam_block/trec07p_format.py similarity index 100% rename from server/hooks/spam_block/trec07p_format.py rename to backend/hooks/spam_block/trec07p_format.py diff --git a/server/hooks/wechat_push/README.md b/backend/hooks/wechat_push/README.md similarity index 100% rename from server/hooks/wechat_push/README.md rename to backend/hooks/wechat_push/README.md diff --git a/server/hooks/wechat_push/wechat_push.go b/backend/hooks/wechat_push/wechat_push.go similarity index 100% rename from server/hooks/wechat_push/wechat_push.go rename to backend/hooks/wechat_push/wechat_push.go diff --git a/server/hooks/wechat_push/wechat_push_test.go b/backend/hooks/wechat_push/wechat_push_test.go similarity index 100% rename from server/hooks/wechat_push/wechat_push_test.go rename to backend/hooks/wechat_push/wechat_push_test.go diff --git a/server/i18n/i18n.go b/backend/i18n/i18n.go similarity index 100% rename from server/i18n/i18n.go rename to backend/i18n/i18n.go diff --git a/server/internal/app/app.go b/backend/internal/app/app.go similarity index 100% rename from server/internal/app/app.go rename to backend/internal/app/app.go diff --git a/server/internal/buildinfo/buildinfo.go b/backend/internal/buildinfo/buildinfo.go similarity index 100% rename from server/internal/buildinfo/buildinfo.go rename to backend/internal/buildinfo/buildinfo.go diff --git a/server/internal/buildinfo/buildinfo_test.go b/backend/internal/buildinfo/buildinfo_test.go similarity index 100% rename from server/internal/buildinfo/buildinfo_test.go rename to backend/internal/buildinfo/buildinfo_test.go diff --git a/server/internal/buildinfo/errors.go b/backend/internal/buildinfo/errors.go similarity index 100% rename from server/internal/buildinfo/errors.go rename to backend/internal/buildinfo/errors.go diff --git a/server/internal/gopop/LICENSE b/backend/internal/gopop/LICENSE similarity index 100% rename from server/internal/gopop/LICENSE rename to backend/internal/gopop/LICENSE diff --git a/server/internal/gopop/action.go b/backend/internal/gopop/action.go similarity index 100% rename from server/internal/gopop/action.go rename to backend/internal/gopop/action.go diff --git a/server/internal/gopop/pop3.go b/backend/internal/gopop/pop3.go similarity index 100% rename from server/internal/gopop/pop3.go rename to backend/internal/gopop/pop3.go diff --git a/server/internal/gopop/pop3_security_test.go b/backend/internal/gopop/pop3_security_test.go similarity index 100% rename from server/internal/gopop/pop3_security_test.go rename to backend/internal/gopop/pop3_security_test.go diff --git a/server/internal/gopop/session.go b/backend/internal/gopop/session.go similarity index 100% rename from server/internal/gopop/session.go rename to backend/internal/gopop/session.go diff --git a/server/internal/release/release.go b/backend/internal/release/release.go similarity index 100% rename from server/internal/release/release.go rename to backend/internal/release/release.go diff --git a/server/internal/release/release_test.go b/backend/internal/release/release_test.go similarity index 100% rename from server/internal/release/release_test.go rename to backend/internal/release/release_test.go diff --git a/server/listen/cron_server/ssl_update.go b/backend/listen/cron_server/ssl_update.go similarity index 100% rename from server/listen/cron_server/ssl_update.go rename to backend/listen/cron_server/ssl_update.go diff --git a/server/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go similarity index 98% rename from server/listen/http_server/api_v1_compat.go rename to backend/listen/http_server/api_v1_compat.go index 90fa7cf0..ab198810 100644 --- a/server/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -11,6 +11,7 @@ import ( // registerV1Routes 注册业务 API 的稳定版本入口。旧入口仍然保留,便于已有客户端平滑迁移。 func registerV1Routes(mux *http.ServeMux) { + registerV1MethodRoute(mux, "/api/v1/security/setup/acknowledge", "/api/security/setup/acknowledge", http.MethodPost, 4<<10, controllers.AcknowledgeSecuritySetup) registerV1MethodRoute(mux, "/api/v1/login", "/api/login", http.MethodPost, 64<<10, controllers.Login) registerV1MethodRoute(mux, "/api/v1/security/verification/request", "/api/security/verification/request", http.MethodPost, 16<<10, controllers.RequestVerification) registerV1MethodRoute(mux, "/api/v1/security/verification/verify", "/api/security/verification/verify", http.MethodPost, 16<<10, controllers.VerifyVerification) diff --git a/server/listen/http_server/api_v1_compat_test.go b/backend/listen/http_server/api_v1_compat_test.go similarity index 100% rename from server/listen/http_server/api_v1_compat_test.go rename to backend/listen/http_server/api_v1_compat_test.go diff --git a/server/listen/http_server/config_route_test.go b/backend/listen/http_server/config_route_test.go similarity index 100% rename from server/listen/http_server/config_route_test.go rename to backend/listen/http_server/config_route_test.go diff --git a/server/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go similarity index 100% rename from server/listen/http_server/http_server.go rename to backend/listen/http_server/http_server.go diff --git a/server/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go similarity index 99% rename from server/listen/http_server/https_server.go rename to backend/listen/http_server/https_server.go index d5acb387..cc216447 100644 --- a/server/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -159,6 +159,7 @@ func contextIterceptor(h controllers.HandlerFunc) http.HandlerFunc { ctx.Role = currentUser.EffectiveRole() ctx.IsAdmin = ctx.Role == access.RoleSuperAdmin || ctx.Role == access.RoleAdmin ctx.MustChangePassword = currentUser.MustChangePassword + ctx.SecuritySetupAcknowledged = currentUser.SecuritySetupAcknowledged controllers.LoadSecurityContext(ctx) } } diff --git a/server/listen/http_server/https_server_test.go b/backend/listen/http_server/https_server_test.go similarity index 100% rename from server/listen/http_server/https_server_test.go rename to backend/listen/http_server/https_server_test.go diff --git a/server/listen/http_server/password_change_route_test.go b/backend/listen/http_server/password_change_route_test.go similarity index 100% rename from server/listen/http_server/password_change_route_test.go rename to backend/listen/http_server/password_change_route_test.go diff --git a/server/listen/http_server/redirect_test.go b/backend/listen/http_server/redirect_test.go similarity index 100% rename from server/listen/http_server/redirect_test.go rename to backend/listen/http_server/redirect_test.go diff --git a/server/listen/http_server/reset_entry_test.go b/backend/listen/http_server/reset_entry_test.go similarity index 100% rename from server/listen/http_server/reset_entry_test.go rename to backend/listen/http_server/reset_entry_test.go diff --git a/backend/listen/http_server/security_setup_browser_test.go b/backend/listen/http_server/security_setup_browser_test.go new file mode 100644 index 00000000..c7d11a28 --- /dev/null +++ b/backend/listen/http_server/security_setup_browser_test.go @@ -0,0 +1,105 @@ +package http_server + +import ( + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/config" + "git.pcux.cn/xuans/Smail/controllers" + "git.pcux.cn/xuans/Smail/db" + "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" + "git.pcux.cn/xuans/Smail/services/auth" + "git.pcux.cn/xuans/Smail/services/credentials" + "git.pcux.cn/xuans/Smail/services/externalemail" + "git.pcux.cn/xuans/Smail/session" +) + +// TestSecuritySetupBrowserFixture 仅显式启用时运行,隔离数据库并复用正式认证路由。 +// 此服务不启动邮件协议和本机管理员通道,不能当作完整部署验收。 +func TestSecuritySetupBrowserFixture(t *testing.T) { + if os.Getenv("SMAIL_BROWSER_FIXTURE") != "1" { + t.Skip("browser fixture is opt-in") + } + root := os.Getenv("SMAIL_BROWSER_ROOT") + password := os.Getenv("SMAIL_BROWSER_PASSWORD") + if root == "" || password == "" { + t.Fatal("isolated root and password are required") + } + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + databasePath := filepath.Join(root, "browser.db") + if _, err := os.Stat(databasePath); !os.IsNotExist(err) { + t.Fatal("fixture requires a fresh database path") + } + config.ROOT_PATH = root + config.DevelopmentMode = false + config.Instance = &config.Config{ + DbType: config.DBTypeSQLite, DbDSN: databasePath, + Domain: "localhost", WebDomain: "localhost", AccessHosts: []string{"127.0.0.1"}, + IsInit: true, SetupState: config.SetupStateReady, HttpsPort: 15443, + } + config.SetInitState(true) + if err := db.Init("browser-fixture"); err != nil { + t.Fatal(err) + } + defer db.Instance.Close() + user := models.User{Account: "xuans", Name: "Test", SessionVersion: 1, SecurityVersion: 1} + user.ApplyRole(access.RoleSuperAdmin) + if err := auth.SetPassword(&user, password); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&user); err != nil { + t.Fatal(err) + } + credential, err := credentials.NewService(db.Instance) + if err != nil { + t.Fatal(err) + } + vault, err := externalemail.NewVault(root) + if err != nil { + t.Fatal(err) + } + external, err := externalemail.NewRepository(db.Instance, vault) + if err != nil { + t.Fatal(err) + } + controllers.ConfigureSecurityRuntime(nil, external, nil, credential) + if err := session.Init(); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + if err := router(mux); err != nil { + t.Fatal(err) + } + frontend := http.FileServer(http.Dir(os.Getenv("SMAIL_BROWSER_DIST"))) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + mux.ServeHTTP(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + frontend.ServeHTTP(w, r) + }) + listener, err := net.Listen("tcp", "127.0.0.1:15443") + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(securityHeaders(session.Instance.LoadAndSave(handler))) + _ = server.Listener.Close() + server.Listener = listener + server.StartTLS() + defer server.Close() + t.Log("isolated HTTPS fixture ready on port 15443") + select { + case <-time.After(30 * time.Minute): + case <-t.Context().Done(): + } +} diff --git a/backend/listen/http_server/security_setup_test.go b/backend/listen/http_server/security_setup_test.go new file mode 100644 index 00000000..7ed28f21 --- /dev/null +++ b/backend/listen/http_server/security_setup_test.go @@ -0,0 +1,41 @@ +package http_server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.pcux.cn/xuans/Smail/dto/response" + "git.pcux.cn/xuans/Smail/session" + "github.com/alexedwards/scs/v2" +) + +func TestSecuritySetupRouteRequiresPostAndCSRF(t *testing.T) { + configureProductionOriginTest(t) + previous := session.Instance + session.Instance = scs.New() + session.Instance.Cookie.Name = "smail_session" + t.Cleanup(func() { session.Instance = previous }) + mux := http.NewServeMux() + registerV1Routes(mux) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/security/setup/acknowledge", nil)) + if recorder.Code != http.StatusMethodNotAllowed { + t.Fatal("GET acknowledgement was accepted") + } + for _, origin := range []string{"https://evil.example.com", "https://mail.example.com"} { + request := httptest.NewRequest(http.MethodPost, "https://mail.example.com/api/v1/security/setup/acknowledge", nil) + request.Header.Set("Origin", origin) + request.AddCookie(&http.Cookie{Name: "smail_session", Value: "test"}) + recorder = httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + var result response.Response + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ErrorNo != response.ParamsError { + t.Fatal("request without valid CSRF was accepted") + } + } +} diff --git a/server/listen/http_server/session_validation_test.go b/backend/listen/http_server/session_validation_test.go similarity index 100% rename from server/listen/http_server/session_validation_test.go rename to backend/listen/http_server/session_validation_test.go diff --git a/server/listen/http_server/setup_security.go b/backend/listen/http_server/setup_security.go similarity index 100% rename from server/listen/http_server/setup_security.go rename to backend/listen/http_server/setup_security.go diff --git a/server/listen/http_server/setup_security_limits_test.go b/backend/listen/http_server/setup_security_limits_test.go similarity index 100% rename from server/listen/http_server/setup_security_limits_test.go rename to backend/listen/http_server/setup_security_limits_test.go diff --git a/server/listen/http_server/setup_security_test.go b/backend/listen/http_server/setup_security_test.go similarity index 100% rename from server/listen/http_server/setup_security_test.go rename to backend/listen/http_server/setup_security_test.go diff --git a/server/listen/http_server/setup_server.go b/backend/listen/http_server/setup_server.go similarity index 100% rename from server/listen/http_server/setup_server.go rename to backend/listen/http_server/setup_server.go diff --git a/server/listen/imap_server/imap_server.go b/backend/listen/imap_server/imap_server.go similarity index 100% rename from server/listen/imap_server/imap_server.go rename to backend/listen/imap_server/imap_server.go diff --git a/server/listen/imap_server/imap_server_test.go b/backend/listen/imap_server/imap_server_test.go similarity index 100% rename from server/listen/imap_server/imap_server_test.go rename to backend/listen/imap_server/imap_server_test.go diff --git a/server/listen/imap_server/numbers.go b/backend/listen/imap_server/numbers.go similarity index 100% rename from server/listen/imap_server/numbers.go rename to backend/listen/imap_server/numbers.go diff --git a/server/listen/imap_server/server.go b/backend/listen/imap_server/server.go similarity index 100% rename from server/listen/imap_server/server.go rename to backend/listen/imap_server/server.go diff --git a/server/listen/imap_server/session_copy.go b/backend/listen/imap_server/session_copy.go similarity index 100% rename from server/listen/imap_server/session_copy.go rename to backend/listen/imap_server/session_copy.go diff --git a/server/listen/imap_server/session_create.go b/backend/listen/imap_server/session_create.go similarity index 100% rename from server/listen/imap_server/session_create.go rename to backend/listen/imap_server/session_create.go diff --git a/server/listen/imap_server/session_delete.go b/backend/listen/imap_server/session_delete.go similarity index 100% rename from server/listen/imap_server/session_delete.go rename to backend/listen/imap_server/session_delete.go diff --git a/server/listen/imap_server/session_expunge.go b/backend/listen/imap_server/session_expunge.go similarity index 100% rename from server/listen/imap_server/session_expunge.go rename to backend/listen/imap_server/session_expunge.go diff --git a/server/listen/imap_server/session_fetch.go b/backend/listen/imap_server/session_fetch.go similarity index 100% rename from server/listen/imap_server/session_fetch.go rename to backend/listen/imap_server/session_fetch.go diff --git a/server/listen/imap_server/session_fetch_test.go b/backend/listen/imap_server/session_fetch_test.go similarity index 100% rename from server/listen/imap_server/session_fetch_test.go rename to backend/listen/imap_server/session_fetch_test.go diff --git a/server/listen/imap_server/session_idle.go b/backend/listen/imap_server/session_idle.go similarity index 100% rename from server/listen/imap_server/session_idle.go rename to backend/listen/imap_server/session_idle.go diff --git a/server/listen/imap_server/session_list.go b/backend/listen/imap_server/session_list.go similarity index 100% rename from server/listen/imap_server/session_list.go rename to backend/listen/imap_server/session_list.go diff --git a/server/listen/imap_server/session_login.go b/backend/listen/imap_server/session_login.go similarity index 100% rename from server/listen/imap_server/session_login.go rename to backend/listen/imap_server/session_login.go diff --git a/server/listen/imap_server/session_move.go b/backend/listen/imap_server/session_move.go similarity index 100% rename from server/listen/imap_server/session_move.go rename to backend/listen/imap_server/session_move.go diff --git a/server/listen/imap_server/session_namespace.go b/backend/listen/imap_server/session_namespace.go similarity index 100% rename from server/listen/imap_server/session_namespace.go rename to backend/listen/imap_server/session_namespace.go diff --git a/server/listen/imap_server/session_poll.go b/backend/listen/imap_server/session_poll.go similarity index 100% rename from server/listen/imap_server/session_poll.go rename to backend/listen/imap_server/session_poll.go diff --git a/server/listen/imap_server/session_rename.go b/backend/listen/imap_server/session_rename.go similarity index 100% rename from server/listen/imap_server/session_rename.go rename to backend/listen/imap_server/session_rename.go diff --git a/server/listen/imap_server/session_search.go b/backend/listen/imap_server/session_search.go similarity index 100% rename from server/listen/imap_server/session_search.go rename to backend/listen/imap_server/session_search.go diff --git a/server/listen/imap_server/session_select.go b/backend/listen/imap_server/session_select.go similarity index 100% rename from server/listen/imap_server/session_select.go rename to backend/listen/imap_server/session_select.go diff --git a/server/listen/imap_server/session_status.go b/backend/listen/imap_server/session_status.go similarity index 100% rename from server/listen/imap_server/session_status.go rename to backend/listen/imap_server/session_status.go diff --git a/server/listen/imap_server/session_store.go b/backend/listen/imap_server/session_store.go similarity index 100% rename from server/listen/imap_server/session_store.go rename to backend/listen/imap_server/session_store.go diff --git a/server/listen/pop3_server/action.go b/backend/listen/pop3_server/action.go similarity index 100% rename from server/listen/pop3_server/action.go rename to backend/listen/pop3_server/action.go diff --git a/server/listen/pop3_server/action_test.go b/backend/listen/pop3_server/action_test.go similarity index 100% rename from server/listen/pop3_server/action_test.go rename to backend/listen/pop3_server/action_test.go diff --git a/server/listen/pop3_server/pop3server.go b/backend/listen/pop3_server/pop3server.go similarity index 100% rename from server/listen/pop3_server/pop3server.go rename to backend/listen/pop3_server/pop3server.go diff --git a/server/listen/pop3_server/protocol_auth_test.go b/backend/listen/pop3_server/protocol_auth_test.go similarity index 100% rename from server/listen/pop3_server/protocol_auth_test.go rename to backend/listen/pop3_server/protocol_auth_test.go diff --git a/server/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go similarity index 100% rename from server/listen/smtp_server/action.go rename to backend/listen/smtp_server/action.go diff --git a/server/listen/smtp_server/action_security_test.go b/backend/listen/smtp_server/action_security_test.go similarity index 100% rename from server/listen/smtp_server/action_security_test.go rename to backend/listen/smtp_server/action_security_test.go diff --git a/server/listen/smtp_server/audit_persistence.go b/backend/listen/smtp_server/audit_persistence.go similarity index 100% rename from server/listen/smtp_server/audit_persistence.go rename to backend/listen/smtp_server/audit_persistence.go diff --git a/server/listen/smtp_server/audit_persistence_test.go b/backend/listen/smtp_server/audit_persistence_test.go similarity index 100% rename from server/listen/smtp_server/audit_persistence_test.go rename to backend/listen/smtp_server/audit_persistence_test.go diff --git a/server/listen/smtp_server/delivery_error.go b/backend/listen/smtp_server/delivery_error.go similarity index 100% rename from server/listen/smtp_server/delivery_error.go rename to backend/listen/smtp_server/delivery_error.go diff --git a/server/listen/smtp_server/delivery_error_test.go b/backend/listen/smtp_server/delivery_error_test.go similarity index 100% rename from server/listen/smtp_server/delivery_error_test.go rename to backend/listen/smtp_server/delivery_error_test.go diff --git a/server/listen/smtp_server/email_authentication_test.go b/backend/listen/smtp_server/email_authentication_test.go similarity index 100% rename from server/listen/smtp_server/email_authentication_test.go rename to backend/listen/smtp_server/email_authentication_test.go diff --git a/server/listen/smtp_server/login.go b/backend/listen/smtp_server/login.go similarity index 100% rename from server/listen/smtp_server/login.go rename to backend/listen/smtp_server/login.go diff --git a/server/listen/smtp_server/protocol_auth_test.go b/backend/listen/smtp_server/protocol_auth_test.go similarity index 100% rename from server/listen/smtp_server/protocol_auth_test.go rename to backend/listen/smtp_server/protocol_auth_test.go diff --git a/server/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go similarity index 100% rename from server/listen/smtp_server/read_content.go rename to backend/listen/smtp_server/read_content.go diff --git a/server/listen/smtp_server/read_content_test.go b/backend/listen/smtp_server/read_content_test.go similarity index 100% rename from server/listen/smtp_server/read_content_test.go rename to backend/listen/smtp_server/read_content_test.go diff --git a/server/listen/smtp_server/smtp.go b/backend/listen/smtp_server/smtp.go similarity index 100% rename from server/listen/smtp_server/smtp.go rename to backend/listen/smtp_server/smtp.go diff --git a/server/listen/smtp_server/submission_sender_test.go b/backend/listen/smtp_server/submission_sender_test.go similarity index 100% rename from server/listen/smtp_server/submission_sender_test.go rename to backend/listen/smtp_server/submission_sender_test.go diff --git a/server/listen/smtp_server/tls_policy_test.go b/backend/listen/smtp_server/tls_policy_test.go similarity index 100% rename from server/listen/smtp_server/tls_policy_test.go rename to backend/listen/smtp_server/tls_policy_test.go diff --git a/server/main.go b/backend/main.go similarity index 100% rename from server/main.go rename to backend/main.go diff --git a/server/main_test.go b/backend/main_test.go similarity index 100% rename from server/main_test.go rename to backend/main_test.go diff --git a/server/models/User.go b/backend/models/User.go similarity index 94% rename from server/models/User.go rename to backend/models/User.go index aae9670e..eaac4cbf 100644 --- a/server/models/User.go +++ b/backend/models/User.go @@ -18,6 +18,7 @@ type User struct { SecurityVersion int `xorm:"security_version unsigned int not null default(1)" json:"-"` MustChangePassword int `xorm:"must_change_password unsigned int not null default(0)" json:"must_change_password"` PasswordChangedAt *time.Time `xorm:"password_changed_at datetime null" json:"-"` + SecuritySetupAcknowledged int `xorm:"security_setup_acknowledged not null default(0)" json:"-"` } // EffectiveRole 返回新角色字段,兼容旧版本只保存 IsAdmin 的账户。 diff --git a/server/models/email.go b/backend/models/email.go similarity index 100% rename from server/models/email.go rename to backend/models/email.go diff --git a/server/models/email_test.go b/backend/models/email_test.go similarity index 100% rename from server/models/email_test.go rename to backend/models/email_test.go diff --git a/server/models/external_email.go b/backend/models/external_email.go similarity index 100% rename from server/models/external_email.go rename to backend/models/external_email.go diff --git a/server/models/group.go b/backend/models/group.go similarity index 100% rename from server/models/group.go rename to backend/models/group.go diff --git a/server/models/plugin.go b/backend/models/plugin.go similarity index 100% rename from server/models/plugin.go rename to backend/models/plugin.go diff --git a/server/models/protocol_credential.go b/backend/models/protocol_credential.go similarity index 100% rename from server/models/protocol_credential.go rename to backend/models/protocol_credential.go diff --git a/server/models/rule.go b/backend/models/rule.go similarity index 100% rename from server/models/rule.go rename to backend/models/rule.go diff --git a/server/models/security_credentials.go b/backend/models/security_credentials.go similarity index 100% rename from server/models/security_credentials.go rename to backend/models/security_credentials.go diff --git a/server/models/session.go b/backend/models/session.go similarity index 100% rename from server/models/session.go rename to backend/models/session.go diff --git a/server/models/user_email.go b/backend/models/user_email.go similarity index 100% rename from server/models/user_email.go rename to backend/models/user_email.go diff --git a/server/models/verification_cleanup_state.go b/backend/models/verification_cleanup_state.go similarity index 100% rename from server/models/verification_cleanup_state.go rename to backend/models/verification_cleanup_state.go diff --git a/server/models/verification_event.go b/backend/models/verification_event.go similarity index 100% rename from server/models/verification_event.go rename to backend/models/verification_event.go diff --git a/server/models/verification_rate_limit.go b/backend/models/verification_rate_limit.go similarity index 100% rename from server/models/verification_rate_limit.go rename to backend/models/verification_rate_limit.go diff --git a/server/models/version.go b/backend/models/version.go similarity index 100% rename from server/models/version.go rename to backend/models/version.go diff --git a/server/plugins/catalog/catalog.go b/backend/plugins/catalog/catalog.go similarity index 100% rename from server/plugins/catalog/catalog.go rename to backend/plugins/catalog/catalog.go diff --git a/server/plugins/catalog/catalog_test.go b/backend/plugins/catalog/catalog_test.go similarity index 100% rename from server/plugins/catalog/catalog_test.go rename to backend/plugins/catalog/catalog_test.go diff --git a/backend/res_init/admin_reset_mode.go b/backend/res_init/admin_reset_mode.go new file mode 100644 index 00000000..7b3564c9 --- /dev/null +++ b/backend/res_init/admin_reset_mode.go @@ -0,0 +1,17 @@ +package res_init + +const developmentDisableAdminResetFlag = "--dev-disable-admin-reset" + +// adminResetDisabledForDevelopment 仅停用开发环境的重置通道,不放宽通道鉴权。 +// 独立参数不改变配置文件、数据库和 Cookie 行为,避免切换开发配置时误用其他数据。 +func adminResetDisabledForDevelopment(development bool, args []string) bool { + if development { + return true + } + for _, arg := range args { + if arg == developmentDisableAdminResetFlag { + return true + } + } + return false +} diff --git a/backend/res_init/admin_reset_mode_test.go b/backend/res_init/admin_reset_mode_test.go new file mode 100644 index 00000000..3e3c484c --- /dev/null +++ b/backend/res_init/admin_reset_mode_test.go @@ -0,0 +1,96 @@ +package res_init + +import ( + "context" + "errors" + "testing" + "time" + + "git.pcux.cn/xuans/Smail/services/adminreset" +) + +func TestAdminResetDisabledForDevelopment(t *testing.T) { + for _, tt := range []struct { + name string + development bool + args []string + want bool + }{ + {name: "production"}, + {name: "legacy development", development: true, want: true}, + {name: "explicit development override", args: []string{developmentDisableAdminResetFlag}, want: true}, + {name: "unrelated flag", args: []string{"--debug"}}, + {name: "partial flag rejected", args: []string{developmentDisableAdminResetFlag + "=false"}}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := adminResetDisabledForDevelopment(tt.development, tt.args); got != tt.want { + t.Fatalf("disabled = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDevelopmentDoesNotOpenAdminResetControl(t *testing.T) { + t.Cleanup(stopAdminResetControl) + listen := func(string, []uint32) (adminreset.ControlServer, error) { + t.Fatal("development must not open an administrator reset endpoint") + return nil, nil + } + if err := startAdminResetControlWithListener(true, adminreset.DefaultControlPath, listen); err != nil { + t.Fatal(err) + } + if adminResetControl != nil || adminResetServeErrors != nil || adminResetCancel != nil { + t.Fatal("development must not leave an active reset channel") + } +} + +func TestProductionAdminResetListenFailureIsFatal(t *testing.T) { + want := errors.New("access denied") + listen := func(path string, allowedUIDs []uint32) (adminreset.ControlServer, error) { + if path != adminreset.DefaultControlPath || len(allowedUIDs) != 1 || allowedUIDs[0] != 0 { + t.Fatal("production reset permissions changed") + } + return nil, want + } + if err := startAdminResetControlWithListener(false, adminreset.DefaultControlPath, listen); !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } +} + +func TestProductionAdminResetRejectsUnavailableControl(t *testing.T) { + listen := func(string, []uint32) (adminreset.ControlServer, error) { return nil, nil } + for _, path := range []string{"", adminreset.DefaultControlPath} { + if err := startAdminResetControlWithListener(false, path, listen); err == nil { + t.Fatal("production must reject unavailable reset control") + } + } +} + +type failingAdminResetControl struct { + err error +} + +func (s *failingAdminResetControl) Serve(context.Context, adminreset.ResetHandler, *adminreset.SessionGuard) error { + return s.err +} + +func (s *failingAdminResetControl) Close() error { return nil } + +func TestProductionAdminResetServeFailureIsReported(t *testing.T) { + t.Cleanup(stopAdminResetControl) + want := errors.New("accept denied") + listen := func(string, []uint32) (adminreset.ControlServer, error) { + return &failingAdminResetControl{err: want}, nil + } + if err := startAdminResetControlWithListener(false, adminreset.DefaultControlPath, listen); err != nil { + t.Fatal(err) + } + select { + case err := <-adminResetServeErrors: + if !errors.Is(err, want) { + t.Fatalf("error = %v, want %v", err, want) + } + case <-time.After(3 * time.Second): + t.Fatal("production must report reset control serve failures") + } +} diff --git a/backend/res_init/config_manager_test.go b/backend/res_init/config_manager_test.go new file mode 100644 index 00000000..e9eb3b24 --- /dev/null +++ b/backend/res_init/config_manager_test.go @@ -0,0 +1,47 @@ +package res_init + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "git.pcux.cn/xuans/Smail/config" +) + +func TestStartConfigManagerUsesServiceRoot(t *testing.T) { + root := t.TempDir() + previousRoot, previousDevelopment := config.ROOT_PATH, config.DevelopmentMode + config.ROOT_PATH, config.DevelopmentMode = root, false + t.Cleanup(func() { + stopConfigManager() + config.ROOT_PATH, config.DevelopmentMode = previousRoot, previousDevelopment + }) + if err := os.MkdirAll(filepath.Join(root, "config"), 0700); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(map[string]any{ + "domain": "mail.example.com", "webDomain": "mail.example.com", + "dbType": "sqlite", "dbDSN": "./config/existing.db", "isInit": true, + "httpPort": 80, "httpsPort": 443, "httpsEnabled": 1, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "config", "config.json"), data, 0600); err != nil { + t.Fatal(err) + } + if _, err := config.MigrateLegacyConfig(root, config.RuntimeValidationOptions{HasSuperAdmin: true, Production: true}); err != nil { + t.Fatal(err) + } + if err := startConfigManager(); err != nil { + t.Fatal(err) + } + manager := config.CurrentManager() + if manager == nil || manager.Snapshot().Generation != 1 { + t.Fatal("migrated configuration manager is unavailable") + } + if _, err := os.Stat(filepath.Join(root, "config", "config")); !os.IsNotExist(err) { + t.Fatal("manager must not create a nested config directory") + } +} diff --git a/server/res_init/init.go b/backend/res_init/init.go similarity index 92% rename from server/res_init/init.go rename to backend/res_init/init.go index 52d81e80..40441800 100644 --- a/server/res_init/init.go +++ b/backend/res_init/init.go @@ -72,7 +72,7 @@ func startConfigManager() error { dbmigration.SetCurrent(nil) return nil } - manager, err := config.NewConfigManager(root, config.ConfigLoaderOptions{Validation: config.RuntimeValidationOptions{ + manager, err := config.NewConfigManager(config.ROOT_PATH, config.ConfigLoaderOptions{Validation: config.RuntimeValidationOptions{ HasSuperAdmin: true, Production: !config.DevelopmentMode, }}) @@ -185,10 +185,9 @@ func Init(serverVersion string) error { if err := startVerificationRuntime(); err != nil { return err } - startAdminResetControl() - if adminResetControl == nil { + if err := startAdminResetControl(); err != nil { stopServices() - return fmt.Errorf("administrator reset control is unavailable") + return err } serviceErrors := make(chan error, 9) adminResetErrors := adminResetServeErrors @@ -223,12 +222,12 @@ func Init(serverVersion string) error { // imap server start startService(serviceErrors, "IMAP TLS", imap_server.StarTLS) - log.Info("管理员重置通道已启动") + log.Info("邮箱服务已启动") restart.SetCurrent(restart.NewSignalTrigger(signal.RestartChan)) select { case err := <-serviceErrors: - log.WithError(err).Error("管理员重置通道启动失败") + log.WithError(err).Error("邮箱服务运行失败") stopServices() return err case <-signal.RestartChan: @@ -397,24 +396,29 @@ func stopVerificationRuntime() { } // startAdminResetControl 启动受保护的本机超级管理员重置通道。 -func startAdminResetControl() { - if adminResetControl != nil { - return - } +func startAdminResetControl() error { + development := adminResetDisabledForDevelopment(config.DevelopmentMode, os.Args[1:]) path := adminreset.DefaultControlPath - if config.DevelopmentMode { - if candidate := os.Getenv("SMAIL_ADMIN_CONTROL_PATH"); candidate != "" { - path = candidate - } + return startAdminResetControlWithListener(development, path, adminreset.ListenControl) +} + +func startAdminResetControlWithListener(development bool, path string, listen func(string, []uint32) (adminreset.ControlServer, error)) error { + if adminResetControl != nil { + return nil + } + if development { + log.Warn("开发模式:已停用本机超级管理员密码重置通道,后端无需为此提升权限;正式部署请移除开发参数") + return nil } if path == "" { - log.Warn("管理员重置通道路径未配置,跳过启动") - return + return fmt.Errorf("administrator reset control path is unavailable") } - control, err := adminreset.ListenControl(path, []uint32{0}) + control, err := listen(path, []uint32{0}) if err != nil { - log.WithError(err).Error("管理员重置通道启动失败") - return + return fmt.Errorf("start administrator reset control: %w", err) + } + if control == nil { + return fmt.Errorf("administrator reset control is unavailable") } adminResetControl = control ctx, cancel := context.WithCancel(context.Background()) @@ -431,6 +435,7 @@ func startAdminResetControl() { } }() log.Info("管理员重置通道已启动") + return nil } func stopAdminResetControl() { diff --git a/server/services/access/access_test.go b/backend/services/access/access_test.go similarity index 100% rename from server/services/access/access_test.go rename to backend/services/access/access_test.go diff --git a/server/services/access/authorize.go b/backend/services/access/authorize.go similarity index 100% rename from server/services/access/authorize.go rename to backend/services/access/authorize.go diff --git a/server/services/access/capability.go b/backend/services/access/capability.go similarity index 100% rename from server/services/access/capability.go rename to backend/services/access/capability.go diff --git a/server/services/access/catalog.go b/backend/services/access/catalog.go similarity index 100% rename from server/services/access/catalog.go rename to backend/services/access/catalog.go diff --git a/server/services/access/matrix_test.go b/backend/services/access/matrix_test.go similarity index 100% rename from server/services/access/matrix_test.go rename to backend/services/access/matrix_test.go diff --git a/server/services/access/role.go b/backend/services/access/role.go similarity index 100% rename from server/services/access/role.go rename to backend/services/access/role.go diff --git a/server/services/adminreset/client.go b/backend/services/adminreset/client.go similarity index 100% rename from server/services/adminreset/client.go rename to backend/services/adminreset/client.go diff --git a/server/services/adminreset/control.go b/backend/services/adminreset/control.go similarity index 100% rename from server/services/adminreset/control.go rename to backend/services/adminreset/control.go diff --git a/server/services/adminreset/control_linux.go b/backend/services/adminreset/control_linux.go similarity index 100% rename from server/services/adminreset/control_linux.go rename to backend/services/adminreset/control_linux.go diff --git a/server/services/adminreset/control_unsupported.go b/backend/services/adminreset/control_unsupported.go similarity index 100% rename from server/services/adminreset/control_unsupported.go rename to backend/services/adminreset/control_unsupported.go diff --git a/server/services/adminreset/control_windows.go b/backend/services/adminreset/control_windows.go similarity index 100% rename from server/services/adminreset/control_windows.go rename to backend/services/adminreset/control_windows.go diff --git a/server/services/adminreset/doctor.go b/backend/services/adminreset/doctor.go similarity index 100% rename from server/services/adminreset/doctor.go rename to backend/services/adminreset/doctor.go diff --git a/server/services/adminreset/doctor_linux.go b/backend/services/adminreset/doctor_linux.go similarity index 100% rename from server/services/adminreset/doctor_linux.go rename to backend/services/adminreset/doctor_linux.go diff --git a/server/services/adminreset/doctor_linux_test.go b/backend/services/adminreset/doctor_linux_test.go similarity index 100% rename from server/services/adminreset/doctor_linux_test.go rename to backend/services/adminreset/doctor_linux_test.go diff --git a/server/services/adminreset/doctor_unsupported.go b/backend/services/adminreset/doctor_unsupported.go similarity index 100% rename from server/services/adminreset/doctor_unsupported.go rename to backend/services/adminreset/doctor_unsupported.go diff --git a/server/services/adminreset/doctor_windows.go b/backend/services/adminreset/doctor_windows.go similarity index 100% rename from server/services/adminreset/doctor_windows.go rename to backend/services/adminreset/doctor_windows.go diff --git a/server/services/adminreset/password_linux.go b/backend/services/adminreset/password_linux.go similarity index 100% rename from server/services/adminreset/password_linux.go rename to backend/services/adminreset/password_linux.go diff --git a/server/services/adminreset/password_linux_test.go b/backend/services/adminreset/password_linux_test.go similarity index 100% rename from server/services/adminreset/password_linux_test.go rename to backend/services/adminreset/password_linux_test.go diff --git a/server/services/adminreset/password_unsupported.go b/backend/services/adminreset/password_unsupported.go similarity index 100% rename from server/services/adminreset/password_unsupported.go rename to backend/services/adminreset/password_unsupported.go diff --git a/server/services/adminreset/password_windows.go b/backend/services/adminreset/password_windows.go similarity index 100% rename from server/services/adminreset/password_windows.go rename to backend/services/adminreset/password_windows.go diff --git a/server/services/adminreset/protocol.go b/backend/services/adminreset/protocol.go similarity index 100% rename from server/services/adminreset/protocol.go rename to backend/services/adminreset/protocol.go diff --git a/server/services/adminreset/protocol_test.go b/backend/services/adminreset/protocol_test.go similarity index 100% rename from server/services/adminreset/protocol_test.go rename to backend/services/adminreset/protocol_test.go diff --git a/server/services/adminreset/reset.go b/backend/services/adminreset/reset.go similarity index 100% rename from server/services/adminreset/reset.go rename to backend/services/adminreset/reset.go diff --git a/server/services/adminreset/reset_test.go b/backend/services/adminreset/reset_test.go similarity index 100% rename from server/services/adminreset/reset_test.go rename to backend/services/adminreset/reset_test.go diff --git a/server/services/adminreset/transport_test.go b/backend/services/adminreset/transport_test.go similarity index 100% rename from server/services/adminreset/transport_test.go rename to backend/services/adminreset/transport_test.go diff --git a/server/services/adminreset/transport_unix.go b/backend/services/adminreset/transport_unix.go similarity index 100% rename from server/services/adminreset/transport_unix.go rename to backend/services/adminreset/transport_unix.go diff --git a/server/services/adminreset/transport_unsupported.go b/backend/services/adminreset/transport_unsupported.go similarity index 100% rename from server/services/adminreset/transport_unsupported.go rename to backend/services/adminreset/transport_unsupported.go diff --git a/server/services/adminreset/transport_windows.go b/backend/services/adminreset/transport_windows.go similarity index 100% rename from server/services/adminreset/transport_windows.go rename to backend/services/adminreset/transport_windows.go diff --git a/server/services/adminreset/transport_windows_test.go b/backend/services/adminreset/transport_windows_test.go similarity index 100% rename from server/services/adminreset/transport_windows_test.go rename to backend/services/adminreset/transport_windows_test.go diff --git a/server/services/attachments/attachments.go b/backend/services/attachments/attachments.go similarity index 100% rename from server/services/attachments/attachments.go rename to backend/services/attachments/attachments.go diff --git a/server/services/attachments/attachments_test.go b/backend/services/attachments/attachments_test.go similarity index 100% rename from server/services/attachments/attachments_test.go rename to backend/services/attachments/attachments_test.go diff --git a/server/services/auth/auth.go b/backend/services/auth/auth.go similarity index 100% rename from server/services/auth/auth.go rename to backend/services/auth/auth.go diff --git a/server/services/auth/auth_test.go b/backend/services/auth/auth_test.go similarity index 100% rename from server/services/auth/auth_test.go rename to backend/services/auth/auth_test.go diff --git a/server/services/auth/auth_timing_test.go b/backend/services/auth/auth_timing_test.go similarity index 100% rename from server/services/auth/auth_timing_test.go rename to backend/services/auth/auth_timing_test.go diff --git a/server/services/auth/dkim_keys.go b/backend/services/auth/dkim_keys.go similarity index 100% rename from server/services/auth/dkim_keys.go rename to backend/services/auth/dkim_keys.go diff --git a/server/services/auth/dkim_keys_test.go b/backend/services/auth/dkim_keys_test.go similarity index 100% rename from server/services/auth/dkim_keys_test.go rename to backend/services/auth/dkim_keys_test.go diff --git a/server/services/auth/identifier.go b/backend/services/auth/identifier.go similarity index 100% rename from server/services/auth/identifier.go rename to backend/services/auth/identifier.go diff --git a/server/services/auth/identifier_test.go b/backend/services/auth/identifier_test.go similarity index 100% rename from server/services/auth/identifier_test.go rename to backend/services/auth/identifier_test.go diff --git a/server/services/auth/protocol_limiter.go b/backend/services/auth/protocol_limiter.go similarity index 100% rename from server/services/auth/protocol_limiter.go rename to backend/services/auth/protocol_limiter.go diff --git a/server/services/auth/protocol_limiter_test.go b/backend/services/auth/protocol_limiter_test.go similarity index 100% rename from server/services/auth/protocol_limiter_test.go rename to backend/services/auth/protocol_limiter_test.go diff --git a/server/services/credentials/protocol.go b/backend/services/credentials/protocol.go similarity index 100% rename from server/services/credentials/protocol.go rename to backend/services/credentials/protocol.go diff --git a/server/services/credentials/service.go b/backend/services/credentials/service.go similarity index 100% rename from server/services/credentials/service.go rename to backend/services/credentials/service.go diff --git a/server/services/credentials/service_test.go b/backend/services/credentials/service_test.go similarity index 100% rename from server/services/credentials/service_test.go rename to backend/services/credentials/service_test.go diff --git a/server/services/dbmigration/service.go b/backend/services/dbmigration/service.go similarity index 100% rename from server/services/dbmigration/service.go rename to backend/services/dbmigration/service.go diff --git a/server/services/dbmigration/service_test.go b/backend/services/dbmigration/service_test.go similarity index 100% rename from server/services/dbmigration/service_test.go rename to backend/services/dbmigration/service_test.go diff --git a/server/services/dbmigration/sql_executor.go b/backend/services/dbmigration/sql_executor.go similarity index 100% rename from server/services/dbmigration/sql_executor.go rename to backend/services/dbmigration/sql_executor.go diff --git a/server/services/dbmigration/sql_executor_test.go b/backend/services/dbmigration/sql_executor_test.go similarity index 100% rename from server/services/dbmigration/sql_executor_test.go rename to backend/services/dbmigration/sql_executor_test.go diff --git a/server/services/dbmigration/sqlite_executor.go b/backend/services/dbmigration/sqlite_executor.go similarity index 100% rename from server/services/dbmigration/sqlite_executor.go rename to backend/services/dbmigration/sqlite_executor.go diff --git a/server/services/dbmigration/sqlite_executor_test.go b/backend/services/dbmigration/sqlite_executor_test.go similarity index 100% rename from server/services/dbmigration/sqlite_executor_test.go rename to backend/services/dbmigration/sqlite_executor_test.go diff --git a/server/services/del_email/del_email.go b/backend/services/del_email/del_email.go similarity index 100% rename from server/services/del_email/del_email.go rename to backend/services/del_email/del_email.go diff --git a/server/services/del_email/del_email_test.go b/backend/services/del_email/del_email_test.go similarity index 100% rename from server/services/del_email/del_email_test.go rename to backend/services/del_email/del_email_test.go diff --git a/server/services/detail/detail.go b/backend/services/detail/detail.go similarity index 100% rename from server/services/detail/detail.go rename to backend/services/detail/detail.go diff --git a/server/services/detail/detail_test.go b/backend/services/detail/detail_test.go similarity index 100% rename from server/services/detail/detail_test.go rename to backend/services/detail/detail_test.go diff --git a/server/services/externalemail/repository.go b/backend/services/externalemail/repository.go similarity index 100% rename from server/services/externalemail/repository.go rename to backend/services/externalemail/repository.go diff --git a/server/services/externalemail/repository_test.go b/backend/services/externalemail/repository_test.go similarity index 100% rename from server/services/externalemail/repository_test.go rename to backend/services/externalemail/repository_test.go diff --git a/server/services/externalemail/vault.go b/backend/services/externalemail/vault.go similarity index 100% rename from server/services/externalemail/vault.go rename to backend/services/externalemail/vault.go diff --git a/server/services/externalemail/vault_test.go b/backend/services/externalemail/vault_test.go similarity index 100% rename from server/services/externalemail/vault_test.go rename to backend/services/externalemail/vault_test.go diff --git a/server/services/group/group.go b/backend/services/group/group.go similarity index 100% rename from server/services/group/group.go rename to backend/services/group/group.go diff --git a/server/services/group/group_test.go b/backend/services/group/group_test.go similarity index 100% rename from server/services/group/group_test.go rename to backend/services/group/group_test.go diff --git a/server/services/list/imap_search.go b/backend/services/list/imap_search.go similarity index 100% rename from server/services/list/imap_search.go rename to backend/services/list/imap_search.go diff --git a/server/services/list/list.go b/backend/services/list/list.go similarity index 100% rename from server/services/list/list.go rename to backend/services/list/list.go diff --git a/server/services/list/list_test.go b/backend/services/list/list_test.go similarity index 100% rename from server/services/list/list_test.go rename to backend/services/list/list_test.go diff --git a/server/services/plugin/catalog.go b/backend/services/plugin/catalog.go similarity index 100% rename from server/services/plugin/catalog.go rename to backend/services/plugin/catalog.go diff --git a/server/services/plugin/catalog_test.go b/backend/services/plugin/catalog_test.go similarity index 100% rename from server/services/plugin/catalog_test.go rename to backend/services/plugin/catalog_test.go diff --git a/server/services/plugin/config.go b/backend/services/plugin/config.go similarity index 100% rename from server/services/plugin/config.go rename to backend/services/plugin/config.go diff --git a/server/services/plugin/config_test.go b/backend/services/plugin/config_test.go similarity index 100% rename from server/services/plugin/config_test.go rename to backend/services/plugin/config_test.go diff --git a/server/services/recovery/service.go b/backend/services/recovery/service.go similarity index 100% rename from server/services/recovery/service.go rename to backend/services/recovery/service.go diff --git a/server/services/recovery/service_test.go b/backend/services/recovery/service_test.go similarity index 100% rename from server/services/recovery/service_test.go rename to backend/services/recovery/service_test.go diff --git a/server/services/recovery/webauthn_recovery_test.go b/backend/services/recovery/webauthn_recovery_test.go similarity index 100% rename from server/services/recovery/webauthn_recovery_test.go rename to backend/services/recovery/webauthn_recovery_test.go diff --git a/server/services/restart/registry.go b/backend/services/restart/registry.go similarity index 100% rename from server/services/restart/registry.go rename to backend/services/restart/registry.go diff --git a/server/services/restart/service.go b/backend/services/restart/service.go similarity index 100% rename from server/services/restart/service.go rename to backend/services/restart/service.go diff --git a/server/services/restart/service_test.go b/backend/services/restart/service_test.go similarity index 100% rename from server/services/restart/service_test.go rename to backend/services/restart/service_test.go diff --git a/server/services/rule/match/base.go b/backend/services/rule/match/base.go similarity index 100% rename from server/services/rule/match/base.go rename to backend/services/rule/match/base.go diff --git a/server/services/rule/match/contains_match.go b/backend/services/rule/match/contains_match.go similarity index 100% rename from server/services/rule/match/contains_match.go rename to backend/services/rule/match/contains_match.go diff --git a/server/services/rule/match/equal_match.go b/backend/services/rule/match/equal_match.go similarity index 100% rename from server/services/rule/match/equal_match.go rename to backend/services/rule/match/equal_match.go diff --git a/server/services/rule/match/regex_match.go b/backend/services/rule/match/regex_match.go similarity index 100% rename from server/services/rule/match/regex_match.go rename to backend/services/rule/match/regex_match.go diff --git a/server/services/rule/match/regex_match_test.go b/backend/services/rule/match/regex_match_test.go similarity index 100% rename from server/services/rule/match/regex_match_test.go rename to backend/services/rule/match/regex_match_test.go diff --git a/server/services/rule/rule.go b/backend/services/rule/rule.go similarity index 100% rename from server/services/rule/rule.go rename to backend/services/rule/rule.go diff --git a/server/services/rule/rule_execution_test.go b/backend/services/rule/rule_execution_test.go similarity index 100% rename from server/services/rule/rule_execution_test.go rename to backend/services/rule/rule_execution_test.go diff --git a/server/services/rule/rule_forward_test.go b/backend/services/rule/rule_forward_test.go similarity index 100% rename from server/services/rule/rule_forward_test.go rename to backend/services/rule/rule_forward_test.go diff --git a/server/services/rule/rule_security_test.go b/backend/services/rule/rule_security_test.go similarity index 100% rename from server/services/rule/rule_security_test.go rename to backend/services/rule/rule_security_test.go diff --git a/server/services/setup/db.go b/backend/services/setup/db.go similarity index 100% rename from server/services/setup/db.go rename to backend/services/setup/db.go diff --git a/server/services/setup/db_database_test.go b/backend/services/setup/db_database_test.go similarity index 100% rename from server/services/setup/db_database_test.go rename to backend/services/setup/db_database_test.go diff --git a/server/services/setup/db_security_test.go b/backend/services/setup/db_security_test.go similarity index 100% rename from server/services/setup/db_security_test.go rename to backend/services/setup/db_security_test.go diff --git a/server/services/setup/dns.go b/backend/services/setup/dns.go similarity index 100% rename from server/services/setup/dns.go rename to backend/services/setup/dns.go diff --git a/server/services/setup/dns_test.go b/backend/services/setup/dns_test.go similarity index 100% rename from server/services/setup/dns_test.go rename to backend/services/setup/dns_test.go diff --git a/server/services/setup/domain.go b/backend/services/setup/domain.go similarity index 100% rename from server/services/setup/domain.go rename to backend/services/setup/domain.go diff --git a/server/services/setup/finish.go b/backend/services/setup/finish.go similarity index 100% rename from server/services/setup/finish.go rename to backend/services/setup/finish.go diff --git a/server/services/setup/finish_test.go b/backend/services/setup/finish_test.go similarity index 100% rename from server/services/setup/finish_test.go rename to backend/services/setup/finish_test.go diff --git a/server/services/setup/ssl/challenge.go b/backend/services/setup/ssl/challenge.go similarity index 100% rename from server/services/setup/ssl/challenge.go rename to backend/services/setup/ssl/challenge.go diff --git a/server/services/setup/ssl/challenge_test.go b/backend/services/setup/ssl/challenge_test.go similarity index 100% rename from server/services/setup/ssl/challenge_test.go rename to backend/services/setup/ssl/challenge_test.go diff --git a/server/services/setup/ssl/dnsProvide.go b/backend/services/setup/ssl/dnsProvide.go similarity index 100% rename from server/services/setup/ssl/dnsProvide.go rename to backend/services/setup/ssl/dnsProvide.go diff --git a/server/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go similarity index 100% rename from server/services/setup/ssl/ssl.go rename to backend/services/setup/ssl/ssl.go diff --git a/server/services/setup/ssl/ssl_test.go b/backend/services/setup/ssl/ssl_test.go similarity index 100% rename from server/services/setup/ssl/ssl_test.go rename to backend/services/setup/ssl/ssl_test.go diff --git a/server/services/setup/ssl/storage.go b/backend/services/setup/ssl/storage.go similarity index 100% rename from server/services/setup/ssl/storage.go rename to backend/services/setup/ssl/storage.go diff --git a/server/services/setup/ssl/storage_test.go b/backend/services/setup/ssl/storage_test.go similarity index 100% rename from server/services/setup/ssl/storage_test.go rename to backend/services/setup/ssl/storage_test.go diff --git a/server/services/setup/ssl/user_certificate_test.go b/backend/services/setup/ssl/user_certificate_test.go similarity index 100% rename from server/services/setup/ssl/user_certificate_test.go rename to backend/services/setup/ssl/user_certificate_test.go diff --git a/server/services/setup/ssl/wildcard_test.go b/backend/services/setup/ssl/wildcard_test.go similarity index 100% rename from server/services/setup/ssl/wildcard_test.go rename to backend/services/setup/ssl/wildcard_test.go diff --git a/server/services/totp/service.go b/backend/services/totp/service.go similarity index 100% rename from server/services/totp/service.go rename to backend/services/totp/service.go diff --git a/server/services/totp/service_test.go b/backend/services/totp/service_test.go similarity index 100% rename from server/services/totp/service_test.go rename to backend/services/totp/service_test.go diff --git a/server/services/verification/cleanup.go b/backend/services/verification/cleanup.go similarity index 100% rename from server/services/verification/cleanup.go rename to backend/services/verification/cleanup.go diff --git a/server/services/verification/cleanup_test.go b/backend/services/verification/cleanup_test.go similarity index 100% rename from server/services/verification/cleanup_test.go rename to backend/services/verification/cleanup_test.go diff --git a/server/services/verification/delivery.go b/backend/services/verification/delivery.go similarity index 100% rename from server/services/verification/delivery.go rename to backend/services/verification/delivery.go diff --git a/server/services/verification/delivery_test.go b/backend/services/verification/delivery_test.go similarity index 100% rename from server/services/verification/delivery_test.go rename to backend/services/verification/delivery_test.go diff --git a/server/services/verification/event_cleanup.go b/backend/services/verification/event_cleanup.go similarity index 100% rename from server/services/verification/event_cleanup.go rename to backend/services/verification/event_cleanup.go diff --git a/server/services/verification/event_cleanup_test.go b/backend/services/verification/event_cleanup_test.go similarity index 100% rename from server/services/verification/event_cleanup_test.go rename to backend/services/verification/event_cleanup_test.go diff --git a/server/services/verification/event_store.go b/backend/services/verification/event_store.go similarity index 100% rename from server/services/verification/event_store.go rename to backend/services/verification/event_store.go diff --git a/server/services/verification/event_store_test.go b/backend/services/verification/event_store_test.go similarity index 100% rename from server/services/verification/event_store_test.go rename to backend/services/verification/event_store_test.go diff --git a/server/services/verification/rate_limit.go b/backend/services/verification/rate_limit.go similarity index 100% rename from server/services/verification/rate_limit.go rename to backend/services/verification/rate_limit.go diff --git a/server/services/verification/rate_limit_test.go b/backend/services/verification/rate_limit_test.go similarity index 100% rename from server/services/verification/rate_limit_test.go rename to backend/services/verification/rate_limit_test.go diff --git a/server/services/verification/runtime.go b/backend/services/verification/runtime.go similarity index 100% rename from server/services/verification/runtime.go rename to backend/services/verification/runtime.go diff --git a/server/services/verification/runtime_test.go b/backend/services/verification/runtime_test.go similarity index 100% rename from server/services/verification/runtime_test.go rename to backend/services/verification/runtime_test.go diff --git a/server/services/verification/service.go b/backend/services/verification/service.go similarity index 100% rename from server/services/verification/service.go rename to backend/services/verification/service.go diff --git a/server/services/verification/service_test.go b/backend/services/verification/service_test.go similarity index 100% rename from server/services/verification/service_test.go rename to backend/services/verification/service_test.go diff --git a/server/services/verification/store.go b/backend/services/verification/store.go similarity index 100% rename from server/services/verification/store.go rename to backend/services/verification/store.go diff --git a/server/services/verification/store_test.go b/backend/services/verification/store_test.go similarity index 100% rename from server/services/verification/store_test.go rename to backend/services/verification/store_test.go diff --git a/server/services/webauthn/service.go b/backend/services/webauthn/service.go similarity index 100% rename from server/services/webauthn/service.go rename to backend/services/webauthn/service.go diff --git a/server/services/webauthn/service_test.go b/backend/services/webauthn/service_test.go similarity index 100% rename from server/services/webauthn/service_test.go rename to backend/services/webauthn/service_test.go diff --git a/server/session/init.go b/backend/session/init.go similarity index 100% rename from server/session/init.go rename to backend/session/init.go diff --git a/server/session/user.go b/backend/session/user.go similarity index 100% rename from server/session/user.go rename to backend/session/user.go diff --git a/server/session/user_test.go b/backend/session/user_test.go similarity index 100% rename from server/session/user_test.go rename to backend/session/user_test.go diff --git a/server/signal/signal.go b/backend/signal/signal.go similarity index 100% rename from server/signal/signal.go rename to backend/signal/signal.go diff --git a/server/utils/address/address.go b/backend/utils/address/address.go similarity index 100% rename from server/utils/address/address.go rename to backend/utils/address/address.go diff --git a/server/utils/address/address_test.go b/backend/utils/address/address_test.go similarity index 100% rename from server/utils/address/address_test.go rename to backend/utils/address/address_test.go diff --git a/server/utils/array/array.go b/backend/utils/array/array.go similarity index 100% rename from server/utils/array/array.go rename to backend/utils/array/array.go diff --git a/server/utils/async/async.go b/backend/utils/async/async.go similarity index 100% rename from server/utils/async/async.go rename to backend/utils/async/async.go diff --git a/server/utils/atomicfile/atomicfile.go b/backend/utils/atomicfile/atomicfile.go similarity index 100% rename from server/utils/atomicfile/atomicfile.go rename to backend/utils/atomicfile/atomicfile.go diff --git a/server/utils/atomicfile/atomicfile_test.go b/backend/utils/atomicfile/atomicfile_test.go similarity index 100% rename from server/utils/atomicfile/atomicfile_test.go rename to backend/utils/atomicfile/atomicfile_test.go diff --git a/server/utils/consts/consts.go b/backend/utils/consts/consts.go similarity index 100% rename from server/utils/consts/consts.go rename to backend/utils/consts/consts.go diff --git a/server/utils/context/context.go b/backend/utils/context/context.go similarity index 97% rename from server/utils/context/context.go rename to backend/utils/context/context.go index ef9cdfef..5b9592f0 100644 --- a/server/utils/context/context.go +++ b/backend/utils/context/context.go @@ -20,6 +20,7 @@ type Context struct { Lang string IsAdmin bool MustChangePassword int + SecuritySetupAcknowledged int RecentAuth bool RecoveryCodeConfigured bool TargetRecoveryConfigured bool diff --git a/server/utils/credential/credential.go b/backend/utils/credential/credential.go similarity index 100% rename from server/utils/credential/credential.go rename to backend/utils/credential/credential.go diff --git a/server/utils/credential/credential_test.go b/backend/utils/credential/credential_test.go similarity index 100% rename from server/utils/credential/credential_test.go rename to backend/utils/credential/credential_test.go diff --git a/server/utils/credential/encryption.go b/backend/utils/credential/encryption.go similarity index 100% rename from server/utils/credential/encryption.go rename to backend/utils/credential/encryption.go diff --git a/server/utils/credential/encryption_test.go b/backend/utils/credential/encryption_test.go similarity index 100% rename from server/utils/credential/encryption_test.go rename to backend/utils/credential/encryption_test.go diff --git a/server/utils/credential/vault.go b/backend/utils/credential/vault.go similarity index 100% rename from server/utils/credential/vault.go rename to backend/utils/credential/vault.go diff --git a/server/utils/credential/vault_test.go b/backend/utils/credential/vault_test.go similarity index 100% rename from server/utils/credential/vault_test.go rename to backend/utils/credential/vault_test.go diff --git a/server/utils/csrf/csrf.go b/backend/utils/csrf/csrf.go similarity index 100% rename from server/utils/csrf/csrf.go rename to backend/utils/csrf/csrf.go diff --git a/server/utils/csrf/csrf_test.go b/backend/utils/csrf/csrf_test.go similarity index 100% rename from server/utils/csrf/csrf_test.go rename to backend/utils/csrf/csrf_test.go diff --git a/server/utils/errors/error.go b/backend/utils/errors/error.go similarity index 100% rename from server/utils/errors/error.go rename to backend/utils/errors/error.go diff --git a/server/utils/file/file.go b/backend/utils/file/file.go similarity index 100% rename from server/utils/file/file.go rename to backend/utils/file/file.go diff --git a/server/utils/file/secure.go b/backend/utils/file/secure.go similarity index 100% rename from server/utils/file/secure.go rename to backend/utils/file/secure.go diff --git a/server/utils/file/secure_test.go b/backend/utils/file/secure_test.go similarity index 100% rename from server/utils/file/secure_test.go rename to backend/utils/file/secure_test.go diff --git a/server/utils/id/logid.go b/backend/utils/id/logid.go similarity index 100% rename from server/utils/id/logid.go rename to backend/utils/id/logid.go diff --git a/server/utils/ip/ip.go b/backend/utils/ip/ip.go similarity index 100% rename from server/utils/ip/ip.go rename to backend/utils/ip/ip.go diff --git a/server/utils/ip/ip_test.go b/backend/utils/ip/ip_test.go similarity index 100% rename from server/utils/ip/ip_test.go rename to backend/utils/ip/ip_test.go diff --git a/server/utils/password/encode.go b/backend/utils/password/encode.go similarity index 100% rename from server/utils/password/encode.go rename to backend/utils/password/encode.go diff --git a/server/utils/password/encode_test.go b/backend/utils/password/encode_test.go similarity index 100% rename from server/utils/password/encode_test.go rename to backend/utils/password/encode_test.go diff --git a/server/utils/query/params.go b/backend/utils/query/params.go similarity index 100% rename from server/utils/query/params.go rename to backend/utils/query/params.go diff --git a/server/utils/query/params_test.go b/backend/utils/query/params_test.go similarity index 100% rename from server/utils/query/params_test.go rename to backend/utils/query/params_test.go diff --git a/server/utils/send/send.go b/backend/utils/send/send.go similarity index 100% rename from server/utils/send/send.go rename to backend/utils/send/send.go diff --git a/server/utils/send/send_test.go b/backend/utils/send/send_test.go similarity index 100% rename from server/utils/send/send_test.go rename to backend/utils/send/send_test.go diff --git a/server/utils/smtp/smtp.go b/backend/utils/smtp/smtp.go similarity index 100% rename from server/utils/smtp/smtp.go rename to backend/utils/smtp/smtp.go diff --git a/server/utils/smtp/smtp_test.go b/backend/utils/smtp/smtp_test.go similarity index 100% rename from server/utils/smtp/smtp_test.go rename to backend/utils/smtp/smtp_test.go diff --git a/server/utils/utf7/LICENSE b/backend/utils/utf7/LICENSE similarity index 100% rename from server/utils/utf7/LICENSE rename to backend/utils/utf7/LICENSE diff --git a/server/utils/utf7/README.md b/backend/utils/utf7/README.md similarity index 100% rename from server/utils/utf7/README.md rename to backend/utils/utf7/README.md diff --git a/server/utils/utf7/decoder.go b/backend/utils/utf7/decoder.go similarity index 100% rename from server/utils/utf7/decoder.go rename to backend/utils/utf7/decoder.go diff --git a/server/utils/utf7/decoder_test.go b/backend/utils/utf7/decoder_test.go similarity index 100% rename from server/utils/utf7/decoder_test.go rename to backend/utils/utf7/decoder_test.go diff --git a/server/utils/utf7/encoder.go b/backend/utils/utf7/encoder.go similarity index 100% rename from server/utils/utf7/encoder.go rename to backend/utils/utf7/encoder.go diff --git a/server/utils/utf7/encoder_test.go b/backend/utils/utf7/encoder_test.go similarity index 100% rename from server/utils/utf7/encoder_test.go rename to backend/utils/utf7/encoder_test.go diff --git a/server/utils/utf7/utf7.go b/backend/utils/utf7/utf7.go similarity index 100% rename from server/utils/utf7/utf7.go rename to backend/utils/utf7/utf7.go diff --git a/server/utils/validation/email.go b/backend/utils/validation/email.go similarity index 100% rename from server/utils/validation/email.go rename to backend/utils/validation/email.go diff --git a/server/utils/validation/email_test.go b/backend/utils/validation/email_test.go similarity index 100% rename from server/utils/validation/email_test.go rename to backend/utils/validation/email_test.go diff --git a/server/utils/validation/input.go b/backend/utils/validation/input.go similarity index 100% rename from server/utils/validation/input.go rename to backend/utils/validation/input.go diff --git a/server/utils/validation/input_test.go b/backend/utils/validation/input_test.go similarity index 100% rename from server/utils/validation/input_test.go rename to backend/utils/validation/input_test.go diff --git a/server/utils/validation/sender.go b/backend/utils/validation/sender.go similarity index 100% rename from server/utils/validation/sender.go rename to backend/utils/validation/sender.go diff --git a/server/utils/validation/sender_test.go b/backend/utils/validation/sender_test.go similarity index 100% rename from server/utils/validation/sender_test.go rename to backend/utils/validation/sender_test.go diff --git a/server/utils/version/version.go b/backend/utils/version/version.go similarity index 100% rename from server/utils/version/version.go rename to backend/utils/version/version.go diff --git a/server/utils/version/version_test.go b/backend/utils/version/version_test.go similarity index 100% rename from server/utils/version/version_test.go rename to backend/utils/version/version_test.go diff --git a/deploy/build.ps1 b/deploy/build.ps1 new file mode 100644 index 00000000..d3968ccd --- /dev/null +++ b/deploy/build.ps1 @@ -0,0 +1,23 @@ +param([switch]$SkipFrontend) + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$output = Join-Path $root 'out\backend' +New-Item -ItemType Directory -Path $output -Force | Out-Null +if (-not $SkipFrontend) { + Push-Location (Join-Path $root 'frontend') + try { + & npm.cmd run build + if ($LASTEXITCODE -ne 0) { throw '前端构建失败' } + } finally { Pop-Location } +} +Push-Location (Join-Path $root 'backend') +try { + & go build -o (Join-Path $output 'smail-dev.exe') . + if ($LASTEXITCODE -ne 0) { throw '开发后端构建失败' } + & go build -o (Join-Path $output 'smail-admin.exe') ./cmd/smail-admin + if ($LASTEXITCODE -ne 0) { throw '管理工具构建失败' } + & go build -o (Join-Path $output 'smail-config.exe') ./cmd/smail-config + if ($LASTEXITCODE -ne 0) { throw '配置工具构建失败' } +} finally { Pop-Location } +Write-Output '构建完成:out/frontend 和 out/backend。正式发行请使用 Makefile 的跨平台构建目标。' diff --git a/deploy/install-systemd.sh b/deploy/install-systemd.sh index 4c4551ca..27569e9a 100644 --- a/deploy/install-systemd.sh +++ b/deploy/install-systemd.sh @@ -11,8 +11,13 @@ install -d -m 0750 /var/lib/smail /run/smail /usr/local/lib/smail if ! getent group smail >/dev/null; then groupadd --system smail; fi if ! getent passwd smail >/dev/null; then useradd --system --gid smail --home-dir /var/lib/smail --shell /usr/sbin/nologin smail; fi chown smail:smail /var/lib/smail -install -o root -g root -m 0755 "$root_dir/server/smail" /usr/local/lib/smail/smail -install -o root -g root -m 0750 "$root_dir/server/smail-admin" /usr/local/lib/smail/smail-admin +case "$(uname -m)" in + x86_64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) echo "不支持的系统架构" >&2; exit 1 ;; +esac +install -o root -g root -m 0755 "$root_dir/out/backend/smail_linux_$arch" /usr/local/lib/smail/smail +install -o root -g root -m 0750 "$root_dir/out/backend/smail-admin_linux_$arch" /usr/local/lib/smail/smail-admin install -o root -g root -m 0644 "$root_dir/deploy/systemd/smail.service" /etc/systemd/system/smail.service install -o root -g root -m 0644 "$root_dir/deploy/systemd/smail-admin.service" /etc/systemd/system/smail-admin.service chown smail:smail /run/smail diff --git a/deploy/install-windows.ps1 b/deploy/install-windows.ps1 index a9f48c36..263dc856 100644 --- a/deploy/install-windows.ps1 +++ b/deploy/install-windows.ps1 @@ -100,14 +100,16 @@ function Stop-SmailService { Assert-Administrator $mainSource = Resolve-PackageFile -Candidates @( - (Join-Path $PackageRoot "server\smail_windows_amd64.exe"), - (Join-Path $PackageRoot "server\smail.exe"), + (Join-Path $PackageRoot "out\backend\smail_windows_amd64.exe"), + (Join-Path $PackageRoot "backend\smail_windows_amd64.exe"), + (Join-Path $PackageRoot "backend\smail.exe"), (Join-Path $PackageRoot "smail_windows_amd64.exe"), (Join-Path $PackageRoot "smail.exe") ) -Description "smail.exe" $adminSource = Resolve-PackageFile -Candidates @( - (Join-Path $PackageRoot "server\smail-admin_windows_amd64.exe"), - (Join-Path $PackageRoot "server\smail-admin.exe"), + (Join-Path $PackageRoot "out\backend\smail-admin_windows_amd64.exe"), + (Join-Path $PackageRoot "backend\smail-admin_windows_amd64.exe"), + (Join-Path $PackageRoot "backend\smail-admin.exe"), (Join-Path $PackageRoot "smail-admin_windows_amd64.exe"), (Join-Path $PackageRoot "smail-admin.exe") ) -Description "smail-admin.exe" diff --git a/deploy/prepare-frontend.mjs b/deploy/prepare-frontend.mjs new file mode 100644 index 00000000..3ad443a1 --- /dev/null +++ b/deploy/prepare-frontend.mjs @@ -0,0 +1,16 @@ +import { cp, mkdir, readdir, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const plugin = process.argv[2] === 'telegram' +const source = path.join(root, 'out', ...(plugin ? ['plugins', 'telegram-ui'] : ['frontend'])) +const target = path.join(root, ...(plugin ? ['plugins', 'pmail_telegram_push', 'hook', 'dist'] : ['backend', 'listen', 'http_server', 'dist'])) + +// Go 嵌入文件不能越过模块目录;这里仅同步构建中间文件,不保存运行数据。 +await readFile(path.join(source, 'index.html')) +await mkdir(target, { recursive: true }) +for (const entry of await readdir(source)) { + await cp(path.join(source, entry), path.join(target, entry), { recursive: true }) +} +console.log('前端产物已生成至 out,并同步 Go 嵌入目录。') diff --git a/deploy/start-dev.ps1 b/deploy/start-dev.ps1 new file mode 100644 index 00000000..bb7f0a79 --- /dev/null +++ b/deploy/start-dev.ps1 @@ -0,0 +1,35 @@ +param([ValidateSet('backend', 'frontend')][string]$Service = 'backend') + +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$logs = Join-Path $root 'test-log\runtime' +New-Item -ItemType Directory -Path $logs -Force | Out-Null +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff' +$previousRoot = $env:SMAIL_ROOT +try { + if ($Service -eq 'backend') { + $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' + $arguments = @('--dev-disable-admin-reset') + $workingDirectory = $env:SMAIL_ROOT + } else { + if (Get-NetTCPConnection -State Listen -LocalPort 15173 -ErrorAction SilentlyContinue) { throw '15173 端口已占用,请先确认并停止旧前端' } + $binary = (Get-Command node -ErrorAction Stop).Source + $vite = Join-Path $root 'frontend\node_modules\vite\bin\vite.js' + if (-not (Test-Path -LiteralPath $vite)) { throw '请先安装 frontend 的依赖' } + $arguments = @(('"{0}"' -f $vite), '--host', '127.0.0.1', '--port', '15173', '--strictPort') + $workingDirectory = Join-Path $root 'frontend' + } + $process = Start-Process -FilePath $binary -ArgumentList $arguments -WorkingDirectory $workingDirectory -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput (Join-Path $logs "$Service-$stamp.stdout.log") ` + -RedirectStandardError (Join-Path $logs "$Service-$stamp.stderr.log") + Start-Sleep -Seconds 3 + $process.Refresh() + if ($process.HasExited) { throw "服务启动失败,请检查 test-log/runtime 中的 $Service 日志" } + Write-Output "$Service 已启动,进程编号:$($process.Id);日志目录:test-log/runtime" +} finally { + $env:SMAIL_ROOT = $previousRoot +} diff --git a/docs/后端接口说明文档.md b/docs/后端接口说明文档.md index 03458182..a05ad784 100644 --- a/docs/后端接口说明文档.md +++ b/docs/后端接口说明文档.md @@ -20,7 +20,7 @@ | 8 | `/attachments/` | `/attachments/{邮件ID}/{内容标识}` | 存在 | 当前只接受 `GET`,用于内联展示附件内容 | | 9 | `/attachments/download/` | `/attachments/download/{邮件ID}/{附件序号}` | 存在 | 当前只接受 `GET`,最后一段是附件序号,不是附件数据库 ID | -当前路由注册以 `server/listen/http_server/http_server.go` 为准,接口处理逻辑以 `server/controllers` 和 `server/controllers/email` 为准。 +当前路由注册以 `backend/listen/http_server/http_server.go` 为准,接口处理逻辑以 `backend/controllers` 和 `backend/controllers/email` 为准。 ## 2. 通用调用约定 @@ -756,15 +756,15 @@ Content-Disposition: attachment; filename="attachment"; filename*=UTF-8''report. 本次对照使用以下当前代码位置作为实现依据: -- 路由注册:`server/listen/http_server/http_server.go` -- 请求上下文、登录态和 CSRF 校验:`server/listen/http_server/https_server.go` -- 登录:`server/controllers/login.go` -- 分组:`server/controllers/group.go` -- 密码修改:`server/controllers/settings.go` -- 附件:`server/controllers/attachments.go` -- 邮件列表:`server/controllers/email/list.go` -- 邮件详情:`server/controllers/email/detail.go` -- 邮件发送:`server/controllers/email/send.go` -- 统一 JSON 响应:`server/dto/response/response.go` +- 路由注册:`backend/listen/http_server/http_server.go` +- 请求上下文、登录态和 CSRF 校验:`backend/listen/http_server/https_server.go` +- 登录:`backend/controllers/login.go` +- 分组:`backend/controllers/group.go` +- 密码修改:`backend/controllers/settings.go` +- 附件:`backend/controllers/attachments.go` +- 邮件列表:`backend/controllers/email/list.go` +- 邮件详情:`backend/controllers/email/detail.go` +- 邮件发送:`backend/controllers/email/send.go` +- 统一 JSON 响应:`backend/dto/response/response.go` 原版对照来源为 PMail Wiki 的《后端接口文档》。原版文档仅作为历史兼容性参考,接口是否可调用以当前 Smail 路由和处理器实现为准。 \ No newline at end of file diff --git a/docs/安全威胁模型与敏感数据流设计.md b/docs/安全威胁模型与敏感数据流设计.md index cba54cb3..106a11b3 100644 --- a/docs/安全威胁模型与敏感数据流设计.md +++ b/docs/安全威胁模型与敏感数据流设计.md @@ -196,10 +196,10 @@ flowchart LR | T-10 | 跨账号、跨用途、跨邮箱或跨浏览器上下文复用挑战 | HMAC 绑定账号、用途、邮箱、资源 ID;浏览器上下文只保存摘要;错误上下文不消费有效挑战 | `verification.TestServiceBindsVerificationToResource`;`verification.TestStoreRejectsDifferentBrowserContextWithoutConsumingChallenge`;`recovery.TestRecoveryBindsChallengeAndTicketToBrowserContext` | | T-11 | 密码或邮箱凭据变更后继续使用旧恢复流程 | `session_version`、外部邮箱 `credential_version` 和统一 `InvalidateUser`;密码重置撤销恢复码、密保问题和 WebAuthn | `recovery.TestRecoveryTicketRejectsChangedSessionVersion`;`controllers.TestPasswordChangeRevokesOfflineCredentialsAtomically`;`adminreset.TestResetSuperAdminOnlyRevokesTargetSessionsAndCredentials` | | T-12 | 恢复流程状态跳跃或直接调用完成接口 | begin/verify/complete 分离;complete 只接受服务端内存票据和浏览器上下文;无票据、过期票据和错误上下文拒绝 | `controllers.TestRecoveryBrowserContextCookieIsRequiredForCompletion`;`recovery.TestRecoveryRejectsCrossUserVerificationAndConsumesTicketOnce` | -| T-13 | 未验证、已停用或他人外部邮箱被用于恢复/登录 | 查询只接受 verified 状态;资源 ID 和 UserID 双重绑定;停用时失效验证码、恢复票据和登录状态 | `externalemail.TestRepositoryBindsVerifiesAndScopesByUser`;`externalemail.TestRepositoryAbandonsOnlyOwnedPendingBinding`;`server/controllers/security.go` 中 `DisableExternalEmail` 的状态失效代码审查 | +| T-13 | 未验证、已停用或他人外部邮箱被用于恢复/登录 | 查询只接受 verified 状态;资源 ID 和 UserID 双重绑定;停用时失效验证码、恢复票据和登录状态 | `externalemail.TestRepositoryBindsVerifiesAndScopesByUser`;`externalemail.TestRepositoryAbandonsOnlyOwnedPendingBinding`;`backend/controllers/security.go` 中 `DisableExternalEmail` 的状态失效代码审查 | | T-14 | 数据库读取者通过运营数据还原账号、邮箱或 IP | 运营字段使用独立 HMAC 指纹;限流维度键哈希;不保存完整值;按期限清理 | `verification.TestEventStorePersistsOnlyOperationalFingerprintsAndCleansAfterRetention`;`verification.TestPersistentRateLimiterDoesNotPersistSensitiveValues` | | T-15 | 主服务重启后旧验证码或恢复流程被恢复使用 | 验证码 Store 的临时密钥和任务只在内存;恢复票据、诱饵挑战和 WebAuthn ceremony 不从数据库恢复 | `verification.TestStoreRestartInvalidatesAllChallenges`;`recovery.TestRecoveryStoresOnlyTicketDigestAndLimitsCapacity`;`webauthn.TestCeremonySessionCapacityAndExpiredPruning` | -| T-16 | 超级管理员通过 Web、外部邮箱或恢复码重置自身密码 | 恢复用户加载明确拒绝 `super_admin`;部署端重置单独走 IPC;没有 Web 路由调用 `ResetSuperAdmin` | `recovery.TestRecoveryDeniesSuperAdministrator`;`controllers.TestRecoveryRejectsWhenDisabled`;路由审计:`server/listen/http_server/http_server.go` 未注册网络超级管理员重置入口 | +| T-16 | 超级管理员通过 Web、外部邮箱或恢复码重置自身密码 | 恢复用户加载明确拒绝 `super_admin`;部署端重置单独走 IPC;没有 Web 路由调用 `ResetSuperAdmin` | `recovery.TestRecoveryDeniesSuperAdministrator`;`controllers.TestRecoveryRejectsWhenDisabled`;路由审计:`backend/listen/http_server/http_server.go` 未注册网络超级管理员重置入口 | | T-17 | 恶意请求超过协议帧、JSON 或 SMTP 头字段限制 | IPC 最大帧 16 KiB、JSON unknown field 拒绝;邮箱和 SMTP Host/From 禁止换行和非法格式;HTTP JSON 单值解析 | `adminreset.TestProtocolRejectsOversizedFrame`;`controllers.TestDecodeJSONRequestRejectsUnsafeBodies`;`verification.TestSMTPHostValidation` | | T-18 | 外部错误把数据库细节或秘密返回给调用方 | IPC 统一返回 `RESET_FAILED`;HTTP 使用稳定业务错误;日志只记录内部错误上下文,不拼接密码、验证码和完整邮箱 | `adminreset.TestWindowsNamedPipeReturnsSafeHandlerFailure`;`controllers.TestSecurityHandlersRejectUnauthenticatedRequests`;安全响应形状测试 | @@ -207,18 +207,18 @@ flowchart LR | 能力 | 主要代码 | 主要测试 | |---|---|---| -| IPC 协议和 nonce | `server/services/adminreset/protocol.go`、`client.go`、`transport_unix.go`、`transport_windows.go` | `server/services/adminreset/protocol_test.go`、`transport_windows_test.go`、`doctor_linux_test.go` | -| 超级管理员事务 | `server/services/adminreset/reset.go` | `server/services/adminreset/reset_test.go` | -| 验证码内存仓库 | `server/services/verification/store.go`、`service.go`、`runtime.go` | `store_test.go`、`service_test.go`、`cleanup_test.go` | -| SMTP 投递 | `server/services/verification/delivery.go` | `delivery_test.go` | -| 限流和运营事件 | `server/services/verification/rate_limit.go`、`event_store.go` | `rate_limit_test.go`、`event_store_test.go`、`event_cleanup_test.go` | -| 外部邮箱加密和生命周期 | `server/services/externalemail/vault.go`、`repository.go` | `vault_test.go`、`repository_test.go` | -| 密保和离线恢复码 | `server/services/credentials/service.go` | `service_test.go` | -| 密码恢复状态机 | `server/services/recovery/service.go` | `service_test.go`、`webauthn_recovery_test.go` | -| 登录二次验证 | `server/controllers/login_verification.go`、`login.go` | `login_security_test.go`、安全控制器测试 | -| WebAuthn ceremony | `server/services/webauthn`、`server/controllers/webauthn.go` | WebAuthn service tests、recovery WebAuthn tests | -| HTTP 公开边界 | `server/controllers/security.go`、`server/listen/http_server/http_server.go` | `security_reset_entry_test.go`、`security_public_response_test.go`、`security_authentication_test.go` | -| 密码重置后的状态失效 | `server/controllers/security.go`、`server/services/recovery`、`server/services/adminreset` | `password_reset_permissions_test.go`、`reset_test.go`、recovery invalidation tests | +| IPC 协议和 nonce | `backend/services/adminreset/protocol.go`、`client.go`、`transport_unix.go`、`transport_windows.go` | `backend/services/adminreset/protocol_test.go`、`transport_windows_test.go`、`doctor_linux_test.go` | +| 超级管理员事务 | `backend/services/adminreset/reset.go` | `backend/services/adminreset/reset_test.go` | +| 验证码内存仓库 | `backend/services/verification/store.go`、`service.go`、`runtime.go` | `store_test.go`、`service_test.go`、`cleanup_test.go` | +| SMTP 投递 | `backend/services/verification/delivery.go` | `delivery_test.go` | +| 限流和运营事件 | `backend/services/verification/rate_limit.go`、`event_store.go` | `rate_limit_test.go`、`event_store_test.go`、`event_cleanup_test.go` | +| 外部邮箱加密和生命周期 | `backend/services/externalemail/vault.go`、`repository.go` | `vault_test.go`、`repository_test.go` | +| 密保和离线恢复码 | `backend/services/credentials/service.go` | `service_test.go` | +| 密码恢复状态机 | `backend/services/recovery/service.go` | `service_test.go`、`webauthn_recovery_test.go` | +| 登录二次验证 | `backend/controllers/login_verification.go`、`login.go` | `login_security_test.go`、安全控制器测试 | +| WebAuthn ceremony | `backend/services/webauthn`、`backend/controllers/webauthn.go` | WebAuthn service tests、recovery WebAuthn tests | +| HTTP 公开边界 | `backend/controllers/security.go`、`backend/listen/http_server/http_server.go` | `security_reset_entry_test.go`、`security_public_response_test.go`、`security_authentication_test.go` | +| 密码重置后的状态失效 | `backend/controllers/security.go`、`backend/services/recovery`、`backend/services/adminreset` | `password_reset_permissions_test.go`、`reset_test.go`、recovery invalidation tests | ## 8. 已知边界、剩余风险和后续任务 diff --git a/docs/安全审查工程优化审核报告.md b/docs/安全审查工程优化审核报告.md index 2b423a1a..b8afa30f 100644 --- a/docs/安全审查工程优化审核报告.md +++ b/docs/安全审查工程优化审核报告.md @@ -25,8 +25,8 @@ | 任务编号 | 优先级 | 审核状态 | 结论 | 关键证据 | | --- | --- | --- | --- | --- | -| SEC-001 | P0 | 未通过 | 不通过 | `server/listen/smtp_server/smtp.go:28` 在 587 端口设置 `AllowInsecureAuth = true` | -| SEC-002 | P0 | 未通过 | 不通过 | `server/listen/smtp_server/action.go:68,92,96` 记录用户名、密码和完整用户对象 | +| SEC-001 | P0 | 未通过 | 不通过 | `backend/listen/smtp_server/smtp.go:28` 在 587 端口设置 `AllowInsecureAuth = true` | +| SEC-002 | P0 | 未通过 | 不通过 | `backend/listen/smtp_server/action.go:68,92,96` 记录用户名、密码和完整用户对象 | | SEC-003 | P0 | 未通过 | 不通过 | `setup_server.go:27-29,52-58` 暴露初始化服务;业务拦截只按 `/api/setup` 放行 | | SEC-004 | P0 | 未通过 | 不通过 | `http_server.go:69-90` 在关闭 HTTPS 时仍注册业务路由和会话 | | SEC-005 | P1 | 未通过 | 不通过 | `group.go:123-126` 只校验邮件所属用户,未校验目标分组所属用户 | @@ -38,7 +38,7 @@ | SEC-011 | P1 | 未通过 | 不通过 | 缺少统一安全响应头;插件 iframe 未设置 `sandbox` | | SEC-012 | P1 | 未通过 | 不通过 | SSL 初始化仅 `os.Stat` 用户路径并直接写入配置,未做受控目录和匹配校验 | | SEC-013 | P1 | 未通过 | 不通过 | `utils/smtp/smtp.go:80` 设置 `InsecureSkipVerify = true` | -| SEC-014 | P1 | 未通过 | 不通过 | `server/main_test.go:35-36` 删除真实配置和数据库 | +| SEC-014 | P1 | 未通过 | 不通过 | `backend/main_test.go:35-36` 删除真实配置和数据库 | | SEC-015 | P2 | 未通过 | 不通过 | 配置、证书和公钥文件使用 `0666`,配置目录使用 `0755` | | SEC-016 | P2 | 未通过 | 不通过 | `services/auth/auth.go:45` 生成 1024 位 RSA DKIM 密钥 | | SEC-017 | P2 | 未通过 | 不通过 | 附件下载使用错误的 `ContentType` 响应头,文件名直接拼接到响应头 | @@ -56,7 +56,7 @@ **审核结果:不通过。** -`server/listen/smtp_server/smtp.go:28` 将 587 端口的 `AllowInsecureAuth` 设置为 `true`。当前配置会使 SMTP 服务器可能在 TLS 升级前声明并接受认证能力,与任务清单要求的“仅 STARTTLS 成功后认证”不一致。 +`backend/listen/smtp_server/smtp.go:28` 将 587 端口的 `AllowInsecureAuth` 设置为 `true`。当前配置会使 SMTP 服务器可能在 TLS 升级前声明并接受认证能力,与任务清单要求的“仅 STARTTLS 成功后认证”不一致。 **优化要求:** @@ -69,7 +69,7 @@ **审核结果:不通过。** -`server/listen/smtp_server/action.go:68` 使用日志输出用户名和明文密码;`action.go:92` 在认证成功时输出完整用户对象,`action.go:96` 在认证失败时再次输出用户名和密码。该问题属于凭据直接泄漏,必须优先处理。 +`backend/listen/smtp_server/action.go:68` 使用日志输出用户名和明文密码;`action.go:92` 在认证成功时输出完整用户对象,`action.go:96` 在认证失败时再次输出用户名和密码。该问题属于凭据直接泄漏,必须优先处理。 **优化要求:** @@ -81,7 +81,7 @@ **审核结果:不通过。** -`server/listen/http_server/setup_server.go:27-29` 将初始化 API 挂载到初始化 HTTP 服务,`setup_server.go:52-58` 直接监听端口。`server/listen/http_server/https_server.go:135-137` 在未初始化时仅按路径 `/api/setup` 放行,没有发现一次性初始化令牌、失败次数限制、速率限制或受控来源校验。 +`backend/listen/http_server/setup_server.go:27-29` 将初始化 API 挂载到初始化 HTTP 服务,`setup_server.go:52-58` 直接监听端口。`backend/listen/http_server/https_server.go:135-137` 在未初始化时仅按路径 `/api/setup` 放行,没有发现一次性初始化令牌、失败次数限制、速率限制或受控来源校验。 **优化要求:** @@ -94,7 +94,7 @@ **审核结果:不通过。** -`server/listen/http_server/http_server.go:69-90` 在 `HttpsEnabled == 2` 时直接注册完整业务路由,并通过 `session.Instance.LoadAndSave(mux)` 启用会话。当前未见统一的 HTTPS 强制策略、Cookie `Secure`、`HttpOnly`、`SameSite`、HSTS 以及安全响应头配置。 +`backend/listen/http_server/http_server.go:69-90` 在 `HttpsEnabled == 2` 时直接注册完整业务路由,并通过 `session.Instance.LoadAndSave(mux)` 启用会话。当前未见统一的 HTTPS 强制策略、Cookie `Secure`、`HttpOnly`、`SameSite`、HSTS 以及安全响应头配置。 **优化要求:** @@ -109,7 +109,7 @@ **审核结果:不通过。** -`server/services/group/group.go:123-126` 的更新语句只通过 `email_id` 和 `user_id` 限制被更新的邮件,但直接使用请求传入的 `groupId`,没有先确认目标分组属于 `ctx.UserID`。攻击者可以尝试引用其他用户的分组 ID。 +`backend/services/group/group.go:123-126` 的更新语句只通过 `email_id` 和 `user_id` 限制被更新的邮件,但直接使用请求传入的 `groupId`,没有先确认目标分组属于 `ctx.UserID`。攻击者可以尝试引用其他用户的分组 ID。 **优化要求:**先查询并锁定目标分组的 `id + user_id`,不存在或不属于当前用户时拒绝操作;再在同一事务内更新邮件,并增加双用户隔离回归测试。 @@ -117,7 +117,7 @@ **审核结果:不通过。** -`server/services/group/group.go:88` 的 `update user_email set group_id=0 where group_id in (...)` 没有附加 `user_id` 条件,存在跨用户修改邮件分组的风险。分组统计查询也存在按 `group_id` 查询而未统一附带用户条件的路径。 +`backend/services/group/group.go:88` 的 `update user_email set group_id=0 where group_id in (...)` 没有附加 `user_id` 条件,存在跨用户修改邮件分组的风险。分组统计查询也存在按 `group_id` 查询而未统一附带用户条件的路径。 **优化要求:**删除分组、清理邮件和统计查询均必须同时带当前用户条件;删除和清理操作必须在同一事务中完成,并增加失败回滚测试。 @@ -125,7 +125,7 @@ **审核结果:不通过,且与既定整改原则直接冲突。** -`server/utils/password/encode.go` 使用两次 MD5 和固定字符串盐。当前登录、初始化、改密、管理员重置和 SMTP 登录仍使用旧 `password.Encode` 路径;HTTP 临时令牌也依赖 MD5 计算。项目没有 Argon2id、统一 `PasswordService` 或 `CredentialValidator`。 +`backend/utils/password/encode.go` 使用两次 MD5 和固定字符串盐。当前登录、初始化、改密、管理员重置和 SMTP 登录仍使用旧 `password.Encode` 路径;HTTP 临时令牌也依赖 MD5 计算。项目没有 Argon2id、统一 `PasswordService` 或 `CredentialValidator`。 **优化要求:** @@ -155,7 +155,7 @@ **审核结果:不通过。** -`server/dto/parsemail/email.go:122,129,132,140-141` 允许全局 `style`、`img` 的 `style`、`a` 的 `style` 及 ` diff --git a/fe/src/views/components/StartupStatus/StartupStatus.css b/frontend/src/views/components/StartupStatus/StartupStatus.css similarity index 100% rename from fe/src/views/components/StartupStatus/StartupStatus.css rename to frontend/src/views/components/StartupStatus/StartupStatus.css diff --git a/fe/src/views/components/StartupStatus/StartupStatus.js b/frontend/src/views/components/StartupStatus/StartupStatus.js similarity index 100% rename from fe/src/views/components/StartupStatus/StartupStatus.js rename to frontend/src/views/components/StartupStatus/StartupStatus.js diff --git a/fe/src/views/components/StartupStatus/StartupStatus.vue b/frontend/src/views/components/StartupStatus/StartupStatus.vue similarity index 100% rename from fe/src/views/components/StartupStatus/StartupStatus.vue rename to frontend/src/views/components/StartupStatus/StartupStatus.vue diff --git a/fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css similarity index 100% rename from fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css rename to frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.css diff --git a/fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js similarity index 100% rename from fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js rename to frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.js diff --git a/fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue b/frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue similarity index 100% rename from fe/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue rename to frontend/src/views/components/ThirdPartyAuthorization/ThirdPartyAuthorization.vue diff --git a/fe/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css similarity index 100% rename from fe/src/views/components/UserManagement/UserManagement.css rename to frontend/src/views/components/UserManagement/UserManagement.css diff --git a/fe/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js similarity index 100% rename from fe/src/views/components/UserManagement/UserManagement.js rename to frontend/src/views/components/UserManagement/UserManagement.js diff --git a/fe/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue similarity index 100% rename from fe/src/views/components/UserManagement/UserManagement.vue rename to frontend/src/views/components/UserManagement/UserManagement.vue diff --git a/fe/vite.config.js b/frontend/vite.config.js similarity index 95% rename from fe/vite.config.js rename to frontend/vite.config.js index 920e9c01..814c1642 100644 --- a/fe/vite.config.js +++ b/frontend/vite.config.js @@ -58,6 +58,8 @@ export default defineConfig(({ mode }) => { } }, build: { + outDir: fileURLToPath(new URL('../out/frontend', import.meta.url)), + emptyOutDir: false, rollupOptions: { output: { manualChunks: { diff --git a/fe/yarn.lock b/frontend/yarn.lock similarity index 100% rename from fe/yarn.lock rename to frontend/yarn.lock diff --git a/plugins/pmail_telegram_push/.github/workflows/release.yaml b/plugins/pmail_telegram_push/.github/workflows/release.yaml index f37fc38a..09204196 100644 --- a/plugins/pmail_telegram_push/.github/workflows/release.yaml +++ b/plugins/pmail_telegram_push/.github/workflows/release.yaml @@ -12,13 +12,16 @@ jobs: goos: ['linux', 'darwin', 'windows'] goarch: ['amd64', 'arm64'] runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/pmail_telegram_push steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: - go-version: '>=1.25' + go-version: '1.26.6' - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -27,19 +30,21 @@ jobs: uses: pnpm/action-setup@v4 with: version: '10' - run_install: true + run_install: false standalone: true - name: Download dependencies run: | + pnpm install --frozen-lockfile go mod tidy go mod download - name: Build binary run: | pnpm build + mkdir -p ../../out/plugins if [[ "${{ matrix.goos }}" = "windows" ]]; then - go build -o pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe -ldflags="-s -w" + go build -o ../../out/plugins/pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}.exe -ldflags="-s -w" else - go build -o pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }} -ldflags="-s -w" + go build -o ../../out/plugins/pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }} -ldflags="-s -w" fi env: GOOS: ${{ matrix.goos }} @@ -50,7 +55,7 @@ jobs: with: allowUpdates: true artifactErrorsFailBuild: true - artifacts: 'pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}*' + artifacts: 'out/plugins/pmail_telegram_push_${{ matrix.goos }}_${{ matrix.goarch }}*' commit: ${{ github.sha }} draft: false prerelease: false diff --git a/plugins/pmail_telegram_push/Makefile b/plugins/pmail_telegram_push/Makefile index 16b17d51..bd602b60 100644 --- a/plugins/pmail_telegram_push/Makefile +++ b/plugins/pmail_telegram_push/Makefile @@ -4,12 +4,13 @@ build: pnpm install pnpm build go mod tidy - go build -o pmail_telegram_push -ldflags "-s -w" + mkdir -p ../../out/plugins + go build -o ../../out/plugins/pmail_telegram_push -ldflags "-s -w" install: mkdir -p ./config ./plugins - cp -v pmail_telegram_push ./plugins/pmail_telegram_push + cp -v ../../out/plugins/pmail_telegram_push ./plugins/pmail_telegram_push cp -v config/pmail_telegram_push.json.example ./config/pmail_telegram_push.json clean: - rm -vf pmail_telegram_push ./plugins/pmail_telegram_push ./config/pmail_telegram_push.json + @echo "请先审查 out/plugins 再清理,保留已安装的插件与配置。" diff --git a/plugins/pmail_telegram_push/go.mod b/plugins/pmail_telegram_push/go.mod index 12d6dc4b..b3181ef1 100644 --- a/plugins/pmail_telegram_push/go.mod +++ b/plugins/pmail_telegram_push/go.mod @@ -40,4 +40,4 @@ require ( xorm.io/builder v0.3.13 // indirect ) -replace git.pcux.cn/xuans/Smail => ../../server +replace git.pcux.cn/xuans/Smail => ../../backend diff --git a/plugins/pmail_telegram_push/package.json b/plugins/pmail_telegram_push/package.json index 8fb396d9..8f49510a 100644 --- a/plugins/pmail_telegram_push/package.json +++ b/plugins/pmail_telegram_push/package.json @@ -11,6 +11,7 @@ "build": "run-p type-check \"build-only {@}\" --", "preview": "vite preview", "build-only": "vite build", + "postbuild-only": "node ../../deploy/prepare-frontend.mjs telegram", "type-check": "vue-tsc --build", "lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore", "lint:eslint": "eslint . --fix", @@ -47,4 +48,4 @@ "vite-svg-loader": "^5.1.0", "vue-tsc": "^3.1.5" } -} \ No newline at end of file +} diff --git a/plugins/pmail_telegram_push/vite.config.ts b/plugins/pmail_telegram_push/vite.config.ts index e0f8f602..f53f0cfe 100644 --- a/plugins/pmail_telegram_push/vite.config.ts +++ b/plugins/pmail_telegram_push/vite.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ }, build: { // 输出目录 - outDir: 'hook/dist', + outDir: fileURLToPath(new URL('../../out/plugins/telegram-ui', import.meta.url)), + emptyOutDir: false, }, }) diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 7412ccbb..00000000 --- a/server/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ - -!server/config/ -!server/config/*.go diff --git a/server/config/config.dev.json b/server/config/config.dev.json deleted file mode 100644 index 5663ac0e..00000000 --- a/server/config/config.dev.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "logLevel": "info", - "domain": "domain.com", - "webDomain": "mail.domain.com", - "dkimPrivateKeyPath": "config/dkim/dkim.priv", - "sslType": "0", - "SSLPrivateKeyPath": "config/ssl/private.key", - "SSLPublicKeyPath": "config/ssl/public.crt", - "dbDSN": "./config/smail.db", - "dbType": "sqlite", - "spamFilterLevel": 2, - "httpPort": 80, - "httpsPort": 443, - "weChatPushAppId": "", - "weChatPushSecret": "", - "weChatPushTemplateId": "", - "weChatPushUserId": "", - "tgChatId": "", - "tgBotToken": "", - "webPushUrl": "", - "webPushToken": "", - "isInit": true, - "httpsEnabled": 2 -} \ No newline at end of file diff --git a/server/config/config.json b/server/config/config.json deleted file mode 100644 index 933af92e..00000000 --- a/server/config/config.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "logLevel": "debug", - "domain": "xuans.com", - "domains": null, - "webDomain": "xuans.com", - "dkimPrivateKeyPath": "config/dkim/dkim.priv", - "sslType": "1", - "SSLPrivateKeyPath": "./config/ssl/xuans.com.key", - "SSLPublicKeyPath": "./config/ssl/xuans.com.crt", - "dbDSN": "./config/smail_temp.db", - "dbType": "sqlite", - "httpsEnabled": 2, - "spamFilterLevel": 0, - "httpPort": 80, - "httpsPort": 443, - "isInit": true -} diff --git a/server/config/config_mysql.json b/server/config/config_mysql.json deleted file mode 100644 index f2cfcdaf..00000000 --- a/server/config/config_mysql.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "logLevel": "info", - "domain": "domain.com", - "webDomain": "mail.domain.com", - "dkimPrivateKeyPath": "config/dkim/dkim.priv", - "sslType": "0", - "SSLPrivateKeyPath": "config/ssl/private.key", - "SSLPublicKeyPath": "config/ssl/public.crt", - "dbDSN": "smail:CHANGE_ME@tcp(127.0.0.1:3306)/smail?charset=utf8mb4&parseTime=True&loc=Local", - "dbType": "mysql", - "spamFilterLevel": 1, - "httpPort": 80, - "httpsPort": 443, - "isInit": true, - "httpsEnabled": 2 -} \ No newline at end of file diff --git a/server/config/dkim/dkim.priv b/server/config/dkim/dkim.priv deleted file mode 100644 index e8c0a0be..00000000 --- a/server/config/dkim/dkim.priv +++ /dev/null @@ -1,16 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBANgOYlTcSbWqw54y -ISRYq6keu/fPj/m7CiVaXtOc9bJhI5uBsd8Jz1eIRzNmqLITL2e5lz2vpQAmpk3z -8Qd2iGCbZBwMXcfPVhAasGH367GRnJw3QPybA0Maob8CnfKDH6Pks3bX7xeef/U3 -ufHYnwgPZUi121ECvPjZLEmXpBrvAgMBAAECgYAxdeGG4cMyBnyvy3QQ2Qe7OKD5 -Uxf3qJzi/jQ1J3qLsncvU1p/38QKmtUJ7Fd0JLY2faMk6P/R8AckU1L7TWRcpafY -fUU4xpuTHpBnMhglOGjEoOfSUFh9iieG8cVreozOOfihFRgRUxu6zfxycymHjGxF -WbdK1zoNLgFgM0DWEQJBAOnkLf4P+pDMCeA/60pll39gIuW+AlMpWrjuA3Yhdu4B -BvC5ea3fIVCWiksfBKihXDZkLG9PZDipy2WiNypPx0kCQQDsep7qHiBPpcp4a9OU -KXolG771iHODId2Nc3zez2xG+2pY5BzoHD2TFdW1/5v9d4q+6u6dUb8v/t2GrMIQ -wrh3AkEAiO0Dm+wA1YoN8hGZjqlhArnmVDdjpwnbyc3Viu/Wb0l8par/uDGbkFFB -Tu8uzAYDNPh6JwQEeUO2Bp7rysJ/uQJBAJKq7rsX2kRr+Gq9vaksHHS9g693ZOVU -8LuVgEIU9fwEXQ4q1P7k3Q/HwBe0JESNiwEkZsAt/l0/PpgTt/17N7sCQBQKcd7e -++RuJCiMc0vMSYAKAmiARJHv/YoxS4tLngtrhu0h5uhr+35c0kJvy6nX0VBb5KV8 -hUK3axAHTSO793o= ------END PRIVATE KEY----- diff --git a/server/config/dkim/dkim.public b/server/config/dkim/dkim.public deleted file mode 100644 index aafcf0d2..00000000 --- a/server/config/dkim/dkim.public +++ /dev/null @@ -1 +0,0 @@ -v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDYDmJU3Em1qsOeMiEkWKupHrv3z4/5uwolWl7TnPWyYSObgbHfCc9XiEczZqiyEy9nuZc9r6UAJqZN8/EHdohgm2QcDF3Hz1YQGrBh9+uxkZycN0D8mwNDGqG/Ap3ygx+j5LN21+8Xnn/1N7nx2J8ID2VItdtRArz42SxJl6Qa7wIDAQAB \ No newline at end of file diff --git a/server/config/ssl/private.key b/server/config/ssl/private.key deleted file mode 100644 index 64282581..00000000 --- a/server/config/ssl/private.key +++ /dev/null @@ -1,27 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -MIIEpAIBAAKCAQEA9uf8Kl44pXTgJSzns6SvySW4IRXh+K1Vi9FF4NRk3BysM0kI -ANFU+VmH7eV/Ql1F4Lrwzalset3QIt5qsBFFDA8me42mF2KYXqIVWvcSg+1MPPMk -Upb9jEPJAEOtMVVWKPu7AntCcWcmNESgSNLXDt7Ok7/hQ93jhiUQzrev6O5jNVvs -fiyQncfMuaHi3joRxojFfq454djswNy0DGswaN9qF+6uGgBgdwc7vOA81YzVDli3 -3NCjwq/RjYu8fFJSZlWtvgYdCEoOe9qhknLzQAD3wCzdkkmIKZP/igg8dvhz71L+ -4NWqDhYe1zmkAXL6Y8CucpiW1FrCRYjpDXtk6wIDAQABAoIBAAa+Y1bM6AMs5Apf -5Zw0fVCjJRpSPK/MHDALcTso0fBpIBLuhbdwAEAnP90xjX5EieoPcRBM9leMw2iQ -Zp2UeyxPJZ/uSIEPAlZjWu33HZxY2OI5Sd6vnRE9sLm/H3XffND1vy/cKf5q8NIw -pagXiiQv1biXXxG5d8NsM79RqQ5Vlsg/ygKb4OtHkGlOFdn/AhvDuOVBsR0ucCdO -qwL8qVI30pCeqAXt/3BdEmqN4LNckhyrEiwLUUgslgfkP3DVKyi3NpNI5sUYTd/i -Ui6eoCbhHSErV+JkFJNWIFy1nWjVNaEmEh9ArYq98xv1Z1Ejn+NHB/LCEhOnIJak -Vg31FRECgYEA++P6YSVTJaXw8NmjLVLiBIWZFK23/3C+m3dkN+Ye8YFMtvvUOLP2 -mFCUA6WiST8R4R0djymmN2+0Aobjv2Qxesv9QmuNeiswQ7A+MKdmUuCy3ai22H5c -XDqKSs7JyXDMeBvPsrtCsYKVIMtago7nE/Ut//oHGgOu83e1dZiA1GUCgYEA+u8w -mHKbs720lD4HaSllxDRgAejnlmtyOZUm63dKxYAmwMaw0f1Y0UBn88hBNFcVxba2 -CIEuz11MuhPGHh6esricmLryw8IPWzchuSZtxHu6OKggWFoCfGR/TxHqVqSLvSI3 -B+GrdMQ2EqdmhzKgWQCazRtRlSrisRbrF5kkdw8CgYAwz/EJOk5ukUWrpsE0W0dp -UOplU3TAj3yga/aDzphYfJH9M7fgdR9oTNUiD8rvHsW8NgQwZgXL4F2lz7X6tNPR -1A3z/RuhfRURSOoES6xMizaeNb+ZHIORa9a4wHHiE3XMILeTDy7Rb1iuzjlv63lk -KLMNU8pkhCo3DA+iBjeQ8QKBgQCTmKUoxiC3NFpG58VMIcFuCrB97xRo8YIaRJTD -40LjsGEa+sN+gFoBmrSKO7u+oYp45ONlVTbHWcWLnZ3mkXQfA194pl2srzSBHoiD -cwsViwEZ2ipMTYUwzZvkUlFX7SkUck+UHzTOVarIhhZUZ37RWv2yruLprnPwXd6h -3r4IGQKBgQCTeZPaMFsuSzBc1twb6NAxEkPhcZXOAFC+xbGM/jcDx5MZqLAi9gVS -ASuZcWLtLsmIkmzBWnZyFm4enSUhTUYW8qpoV+KDM4RH8yBjSvitrr9lToM7nnnX -eauj/AgwH9D6pt3JtyPeqVOLeCo9LWSQBkKJKxdkPPIDaz4OlgRn3A== ------END RSA PRIVATE KEY----- diff --git a/server/config/ssl/public.crt b/server/config/ssl/public.crt deleted file mode 100644 index 28db02a3..00000000 --- a/server/config/ssl/public.crt +++ /dev/null @@ -1,22 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDmTCCAoECFH0cnkhiRVka2ZAWxpjtD+JgCv2GMA0GCSqGSIb3DQEBCwUAMIGI -MQswCQYDVQQGEwJDTjETMBEGA1UECAwKU29tZS1TdGF0ZTELMAkGA1UEBwwCQkox -DjAMBgNVBAoMBVBNYWlsMQ4wDAYDVQQLDAVQTWFpbDEWMBQGA1UEAwwNKi50ZXN0 -LmRvbWFpbjEfMB0GCSqGSIb3DQEJARYQdGVzdEB0ZXN0LmRvbWFpbjAeFw0yNDA3 -MDUwODA5MTVaFw0zNDA3MDMwODA5MTVaMIGIMQswCQYDVQQGEwJDTjETMBEGA1UE -CAwKU29tZS1TdGF0ZTELMAkGA1UEBwwCQkoxDjAMBgNVBAoMBVBNYWlsMQ4wDAYD -VQQLDAVQTWFpbDEWMBQGA1UEAwwNKi50ZXN0LmRvbWFpbjEfMB0GCSqGSIb3DQEJ -ARYQdGVzdEB0ZXN0LmRvbWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBAPbn/CpeOKV04CUs57Okr8kluCEV4fitVYvRReDUZNwcrDNJCADRVPlZh+3l -f0JdReC68M2pbHrd0CLearARRQwPJnuNphdimF6iFVr3EoPtTDzzJFKW/YxDyQBD -rTFVVij7uwJ7QnFnJjREoEjS1w7ezpO/4UPd44YlEM63r+juYzVb7H4skJ3HzLmh -4t46EcaIxX6uOeHY7MDctAxrMGjfahfurhoAYHcHO7zgPNWM1Q5Yt9zQo8Kv0Y2L -vHxSUmZVrb4GHQhKDnvaoZJy80AA98As3ZJJiCmT/4oIPHb4c+9S/uDVqg4WHtc5 -pAFy+mPArnKYltRawkWI6Q17ZOsCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAIl8L -nL+wICckIRTfO4K4+F+7UHgLI2iTYRic6Hsy3K0aPvZAdVJiOlz0qaEcce+bFzj7 -BZiHQWDgiPF4vNFqcmas4oFV+Au2K8AoYQFJrq+3dtiUaMStT7JkjNjci3C2NhPO -U7Gjq2OJx6IrJr7ECr2SFW4Sstw/h+s/mBfNl2BbE7kmT1xu/lyxpIiT7bYgSk/l -A3cJFE1JIoa7eUPpV4Kh0titwJnDYVfQmEeBNYivyeNwe4hiHtiZDamI6H7Wu95b -ldRRiFELoVs0GCn/ttIaSFvGUPeahn9rTNUUkjp4Un0RxuQFx8umQYl50zt1GZ1X -DICmWUwYYejqdXrrww== ------END CERTIFICATE----- diff --git a/server/config/ssl/server.csr b/server/config/ssl/server.csr deleted file mode 100644 index bbe4828b..00000000 --- a/server/config/ssl/server.csr +++ /dev/null @@ -1,18 +0,0 @@ ------BEGIN CERTIFICATE REQUEST----- -MIICzjCCAbYCAQAwgYgxCzAJBgNVBAYTAkNOMRMwEQYDVQQIDApTb21lLVN0YXRl -MQswCQYDVQQHDAJCSjEOMAwGA1UECgwFUE1haWwxDjAMBgNVBAsMBVBNYWlsMRYw -FAYDVQQDDA0qLnRlc3QuZG9tYWluMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QHRlc3Qu -ZG9tYWluMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA9uf8Kl44pXTg -JSzns6SvySW4IRXh+K1Vi9FF4NRk3BysM0kIANFU+VmH7eV/Ql1F4Lrwzalset3Q -It5qsBFFDA8me42mF2KYXqIVWvcSg+1MPPMkUpb9jEPJAEOtMVVWKPu7AntCcWcm -NESgSNLXDt7Ok7/hQ93jhiUQzrev6O5jNVvsfiyQncfMuaHi3joRxojFfq454djs -wNy0DGswaN9qF+6uGgBgdwc7vOA81YzVDli33NCjwq/RjYu8fFJSZlWtvgYdCEoO -e9qhknLzQAD3wCzdkkmIKZP/igg8dvhz71L+4NWqDhYe1zmkAXL6Y8CucpiW1FrC -RYjpDXtk6wIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAPSNiDkAGFhMWFzQns+a -6+ujoI8Lf7baN/LklEEMRV2xq5sonj72ZU4PJDAyNFVj+pCKDOH5mb0r5ceRKYx/ -HOlXxYvDhvk3t9mAGrRwG3UhzbCSbIIcvdKbU4FDGaRIzrSsvcv9fUDnw5fKTL61 -IRDNewABlCncsUYfHrXeuMtqdiWyZMfYjiHDunCCo/FbrG70q9LjMHT4zHl9LV8T -jnrQzX0UaxBgLYDPEJX+2fqaXObv1HHSWlgZ6Ov9eRRKN2oRkb+KIbaTnlQ7Z7Y9 -kzwla/WOKJDL5FA7275tVpC553+rglq/0Jy9Hiq71Sis9gnG8eTYsghN5FHZSMFT -a1s= ------END CERTIFICATE REQUEST----- diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..9fe16984 --- /dev/null +++ b/test/README.md @@ -0,0 +1,39 @@ +# 测试目录 + +## 目录用途 + +- `frontend/`:前端自动测试及其构建路径约束测试,直接复用 `frontend/node_modules`。 +- `tools/password-check/`:离线密码哈希校验工具,只通过环境变量接收输入,不输出密码和哈希。 +- `manual/smtp/`:人工 SMTP 发信脚本,可能向外部发送真实邮件,不包含在自动测试中。运行前需自行检查收发件人和服务器配置。 +- `plugins/spam_block/`:垃圾邮件模型测试,需要额外安装 TensorFlow 并准备相应模型;不包含在默认测试中。 +- `archive/`:历史测试脚本和补丁,仅留作检查证据,路径和运行环境可能已失效,不应直接执行,也不提交其中的本机配置。 +- `run.mjs`:统一自动测试入口。 + +## 执行方法 + +在项目根目录运行: + +```sh +node test/run.mjs all +node test/run.mjs frontend +node test/run.mjs backend +node test/run.mjs backend -count=1 ./... +node test/run.mjs backend -race ./... +``` + +首次执行 Go 测试前,需要在 `frontend/` 安装依赖并运行 `npm run build`,为 `go:embed` 提供前端文件。默认命令不会启动开发服务、清空真实数据库或发送真实邮件。 + +每次运行自动创建 `test-log/run-时间-进程号/`,保存前后端日志及临时文件。子进程的 `SMAIL_ROOT` 和临时目录指向该次测试目录,不继承开发后端的真实配置根目录。MySQL/PostgreSQL 集成测试仍需单独准备专用数据库,不能指向业务数据库。 + +Windows 密码检查工具的构建命令: + +```powershell +Set-Location test/tools/password-check +go build -o ../../../out/test-tools/password-check.exe . +``` + +## Go 测试源码的必要例外 + +`backend/` 中的同包 `*_test.go` 保持原位,其中许多测试使用包内私有函数或相对路径。将其机械移动到独立目录会破坏 Go 测试语义,因此只集中运行入口,不改变业务包的可见性和 API。后续跨模块、浏览器和独立协议测试应放到本目录。 + +截图、测试生成的 Logo 检查图、浏览器配置、测试数据库、报告和日志都放到 `test-log/`;测试工具二进制放到 `out/test-tools/`。运行用 Logo、文档演示图、模型训练脚本及数据不是临时测试产物,不作清理。 diff --git a/fe/tests/auth.test.mjs b/test/frontend/auth.test.mjs similarity index 98% rename from fe/tests/auth.test.mjs rename to test/frontend/auth.test.mjs index 526f5639..af45966e 100644 --- a/fe/tests/auth.test.mjs +++ b/test/frontend/auth.test.mjs @@ -6,7 +6,7 @@ import { classifyAuthenticationError, classifyAuthenticationResponse, runSharedAuthRefresh -} from "../src/utils/auth.mjs"; +} from "../../frontend/src/utils/auth.mjs"; test("认证响应会区分已登录、未登录和服务不可用", () => { assert.equal( diff --git a/test/frontend/config-settings.test.mjs b/test/frontend/config-settings.test.mjs new file mode 100644 index 00000000..d2cef24a --- /dev/null +++ b/test/frontend/config-settings.test.mjs @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/components/ConfigSettings/ConfigSettings.js', import.meta.url), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'onMounted', 'reactive', 'ref', 'ElMessage', 'ElMessageBox', 'http', 'useGlobalStatusStore'] +) + +function fixture(get) { + const messages = [] + const page = createPage(computed, () => {}, reactive, ref, + { error: message => messages.push(message) }, {}, { get }, () => ({ hasCapability: () => true })) + return { page, messages } +} + +test('旧版配置仅请求状态,不并发请求分区、备份和审计', async () => { + const requests = [] + const { page, messages } = fixture(async path => { + requests.push(path) + return { errorNo: 0, data: { available: false, unavailableReason: '在线配置管理尚未启用' } } + }) + await page.loadAll() + assert.deepEqual(requests, ['/api/v1/config/status']) + assert.equal(page.availabilityError.value, '在线配置管理尚未启用') + assert.deepEqual(messages, []) + assert.equal(page.loading.value, false) +}) + +test('备份和审计失败只显示局部错误,不阻挡配置编辑区域', async () => { + const { page, messages } = fixture(async path => { + if (path.endsWith('/status')) return { errorNo: 0, data: { available: true } } + if (path.endsWith('/base')) return { errorNo: 0, data: {} } + throw new Error('配置服务暂时不可用') + }) + await page.loadAll() + assert.equal(page.availabilityError.value, '') + assert.equal(page.editorText.value, '{}') + assert.equal(page.backupsError.value, '配置服务暂时不可用') + assert.equal(page.auditError.value, '配置服务暂时不可用') + assert.deepEqual(messages, []) + await page.loadBackups() + assert.deepEqual(messages, []) + assert.equal(page.backupsError.value, '配置服务暂时不可用') +}) + +test('附属模块失败清除旧数据和回滚选择,重试成功清除错误', async () => { + let fail = true + const { page } = fixture(async () => { + if (fail) throw new Error('网络不可用') + return { errorNo: 0, data: [{ generation: 2 }] } + }) + page.backups.value = [{ generation: 1 }] + page.auditItems.value = [{ id: 'old' }] + page.rollbackGeneration.value = 1 + page.rollbackPreview.value = { targetGeneration: 1 } + page.rollbackConfirmed.value = true + await Promise.all([page.loadBackups(), page.loadAudit()]) + assert.deepEqual(page.backups.value, []) + assert.deepEqual(page.auditItems.value, []) + assert.equal(page.rollbackGeneration.value, 0) + assert.equal(page.rollbackPreview.value, null) + assert.equal(page.rollbackConfirmed.value, false) + fail = false + await Promise.all([page.loadBackups(), page.loadAudit()]) + assert.equal(page.backupsError.value, '') + assert.equal(page.auditError.value, '') + assert.equal(page.backups.value.length, 1) + assert.equal(page.auditItems.value.length, 1) + assert.equal(page.backupsLoading.value, false) + assert.equal(page.auditLoading.value, false) +}) + +test('状态请求失败后可重试恢复,不保留不可用提示', async () => { + let available = false + const { page } = fixture(async path => { + if (!available) throw new Error('网络不可用') + return { errorNo: 0, data: path.endsWith('/status') ? { available: true } : {} } + }) + await page.loadAll() + assert.equal(page.availabilityError.value, '网络不可用') + available = true + await page.loadAll() + assert.equal(page.availabilityError.value, '') +}) diff --git a/fe/tests/navigation.test.mjs b/test/frontend/navigation.test.mjs similarity index 98% rename from fe/tests/navigation.test.mjs rename to test/frontend/navigation.test.mjs index 2d3549e2..5701c251 100644 --- a/fe/tests/navigation.test.mjs +++ b/test/frontend/navigation.test.mjs @@ -6,7 +6,7 @@ import { getCurrentLoginRedirect, getSafeLoginRedirect, createSetupLocation -} from "../src/utils/navigation.mjs"; +} from "../../frontend/src/utils/navigation.mjs"; test("登录重定向只接受非登录页的站内路径", () => { assert.equal(getSafeLoginRedirect("/list?tag=inbox"), "/list?tag=inbox"); diff --git a/test/frontend/output-paths.test.mjs b/test/frontend/output-paths.test.mjs new file mode 100644 index 00000000..82328b74 --- /dev/null +++ b/test/frontend/output-paths.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const root = fileURLToPath(new URL('../../', import.meta.url)) +const read = relative => readFile(path.join(root, relative), 'utf8') + +test('跨平台构建输出不得越过项目 out 目录', async () => { + const content = await read('Makefile') + const commands = [...content.matchAll(/cd ([^\s]+) && [^\n]*go build [^\n]* -o ([^\s]+) /g)] + assert.equal(commands.length, 30) + for (const [, directory, output] of commands) { + const relative = path.relative(path.join(root, 'out'), path.resolve(root, directory, output)) + assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), output) + } +}) + +test('前端构建必须自动同步 Go 嵌入资源', async () => { + const frontend = JSON.parse(await read('frontend/package.json')) + const telegram = JSON.parse(await read('plugins/pmail_telegram_push/package.json')) + assert.equal(frontend.scripts.postbuild, 'node ../deploy/prepare-frontend.mjs') + assert.equal(telegram.scripts['postbuild-only'], 'node ../../deploy/prepare-frontend.mjs telegram') + assert.match(await read('frontend/vite.config.js'), /\.\.\/out\/frontend/) + assert.match(await read('plugins/pmail_telegram_push/vite.config.ts'), /\.\.\/\.\.\/out\/plugins\/telegram-ui/) +}) + +test('开发启动必须固定运行配置根目录并集中保存日志', async () => { + const content = await read('deploy/start-dev.ps1') + assert.match(content, /\$env:SMAIL_ROOT = Join-Path \$root 'backend'/) + assert.match(content, /out\\backend\\smail-dev\.exe/) + assert.match(content, /test-log\\runtime/) + assert.match(content, /-WindowStyle Hidden/) + assert.ok(content.charCodeAt(0) === 0xfeff, 'Windows PowerShell 5 脚本应有 UTF-8 BOM') +}) + +test('安全工作流必须先构建嵌入资源再执行 Go 测试', async () => { + for (const file of ['.github/workflows/release.yml', '.github/workflows/security.yml']) { + const content = (await read(file)).split(' security:')[1] + assert.ok(content) + const prepare = content.indexOf('yarn build') + const goTest = content.indexOf('node test/run.mjs backend') + assert.ok(prepare >= 0 && goTest > prepare, file) + } +}) diff --git a/test/frontend/security-setup.test.mjs b/test/frontend/security-setup.test.mjs new file mode 100644 index 00000000..412e08fa --- /dev/null +++ b/test/frontend/security-setup.test.mjs @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { compileFunction } from 'node:vm' +import { computed, reactive, ref } from './vue.mjs' + +const source = readFileSync(new URL('../../frontend/src/views/components/SecuritySetupPrompt/SecuritySetupPrompt.js', import.meta.url), 'utf8') +const createPage = compileFunction( + source.replace(/^import .*$/gm, '').replace('export function usePage', 'function usePage') + '\nreturn usePage()', + ['computed', 'ref', 'http', 'useGlobalStatusStore'] +) + +function fixture(post = async () => ({ errorNo: 0 })) { + const store = reactive({ + isLogin: true, + role: 'super_admin', + userInfos: { account: 'root', showSecuritySetupPrompt: true, must_change_password: 0, webAuthnEnabled: false }, + settingsDrawerVisible: false, + dismissRecoveryPrompt() { this.recoveryPromptDismissed = true } + }) + return { store, page: createPage(computed, ref, { post }, () => store) } +} + +test('初始化超级管理员首次登录显示三项引导,强制改密时延后', () => { + const { store, page } = fixture() + assert.equal(page.visible.value, true) + assert.deepEqual(Array.from(page.options.value, item => item.key), ['question', 'external-email', 'webauthn']) + store.userInfos.must_change_password = 1 + assert.equal(page.visible.value, false) + store.userInfos.must_change_password = 0 + assert.equal(page.visible.value, true) +}) + +test('确认成功后关闭并打开目标设置,刷新后的持久化状态不再弹出', async () => { + const requests = [] + const { store, page } = fixture(async path => { requests.push(path); return { errorNo: 0 } }) + await page.acknowledge('external-email') + assert.equal(page.visible.value, false) + assert.equal(store.settingsActiveTab, 'security') + assert.equal(store.securityActiveSection, 'external-email') + assert.equal(store.settingsDrawerVisible, true) + assert.deepEqual(requests, ['/api/v1/security/setup/acknowledge']) + store.userInfos = { account: 'root', showSecuritySetupPrompt: false } + assert.equal(page.visible.value, false) +}) + +test('保存失败保留引导,可重试且不将查看状态误记为设置完成', async () => { + const { store, page } = fixture(async () => { throw new Error('请求失败') }) + await page.acknowledge() + assert.equal(page.visible.value, true) + assert.equal(page.error.value, '请求失败') + assert.equal(page.saving.value, false) + assert.equal(store.settingsDrawerVisible, false) + assert.equal(store.userInfos.recoveryCodesConfigured, undefined) +}) + +test('并发点击只提交一次,切换账号后不修改新账号状态', async () => { + let finish + let calls = 0 + const { store, page } = fixture(() => { calls++; return new Promise(resolve => { finish = resolve }) }) + const pending = page.acknowledge('question') + await page.acknowledge('webauthn') + assert.equal(calls, 1) + store.userInfos = { account: 'alice', showSecuritySetupPrompt: true } + finish({ errorNo: 0 }) + await pending + assert.equal(page.visible.value, true) + assert.equal(store.settingsDrawerVisible, false) +}) diff --git a/test/frontend/vue.mjs b/test/frontend/vue.mjs new file mode 100644 index 00000000..420d4669 --- /dev/null +++ b/test/frontend/vue.mjs @@ -0,0 +1,5 @@ +import { createRequire } from 'node:module' + +// 共用前端依赖,不在测试目录安装第二份 Vue。 +const require = createRequire(new URL('../../frontend/package.json', import.meta.url)) +export const { computed, reactive, ref } = require('vue') diff --git a/server/listen/smtp_server/smtp_test/sendEmailTest.py b/test/manual/smtp/sendEmailTest.py similarity index 100% rename from server/listen/smtp_server/smtp_test/sendEmailTest.py rename to test/manual/smtp/sendEmailTest.py diff --git a/server/hooks/spam_block/test.py b/test/plugins/spam_block/backend_test.py similarity index 82% rename from server/hooks/spam_block/test.py rename to test/plugins/spam_block/backend_test.py index 8d5550a0..596e5b0c 100644 --- a/server/hooks/spam_block/test.py +++ b/test/plugins/spam_block/backend_test.py @@ -1,9 +1,10 @@ import os +from pathlib import Path import numpy as np os.environ['TF_CPP_MIN_LOG_LEVEL'] = '1' # silence TF INFO messages import tensorflow as tf -save_path = './emotion_model/1' +save_path = str(Path(__file__).resolve().parents[3] / 'backend/hooks/spam_block/emotion_model/1') model = tf.keras.models.load_model(save_path, compile=False) @@ -31,4 +32,4 @@ def predict_emotions(txt): maxClass = predict_emotions("各位同事请注意 这里是110,请大家立刻把银行卡账号密码回复发给我!") -print("这个邮件属于:",CLASSES[maxClass]) \ No newline at end of file +print("这个邮件属于:",CLASSES[maxClass]) diff --git a/plugins/spam_block/test.py b/test/plugins/spam_block/legacy_test.py similarity index 82% rename from plugins/spam_block/test.py rename to test/plugins/spam_block/legacy_test.py index 8d5550a0..cff850a9 100644 --- a/plugins/spam_block/test.py +++ b/test/plugins/spam_block/legacy_test.py @@ -1,9 +1,10 @@ import os +from pathlib import Path import numpy as np os.environ['TF_CPP_MIN_LOG_LEVEL'] = '1' # silence TF INFO messages import tensorflow as tf -save_path = './emotion_model/1' +save_path = str(Path(__file__).resolve().parents[3] / 'plugins/spam_block/emotion_model/1') model = tf.keras.models.load_model(save_path, compile=False) @@ -31,4 +32,4 @@ def predict_emotions(txt): maxClass = predict_emotions("各位同事请注意 这里是110,请大家立刻把银行卡账号密码回复发给我!") -print("这个邮件属于:",CLASSES[maxClass]) \ No newline at end of file +print("这个邮件属于:",CLASSES[maxClass]) diff --git a/test/run.mjs b/test/run.mjs new file mode 100644 index 00000000..8da49318 --- /dev/null +++ b/test/run.mjs @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process' +import { createWriteStream } from 'node:fs' +import { mkdir, readdir } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const [suite = 'all', ...extra] = process.argv.slice(2) +if (!['all', 'frontend', 'backend'].includes(suite)) { + console.error('用法:node test/run.mjs [all|frontend|backend] [Go 测试参数]') + process.exit(2) +} +const stamp = new Date().toISOString().replace(/[:.]/g, '-') +const directory = path.join(root, 'test-log', `run-${stamp}-${process.pid}`) +const temporary = path.join(directory, 'tmp') +await mkdir(temporary, { recursive: true }) + +async function run(name, command, args, cwd) { + const log = createWriteStream(path.join(directory, `${name}.log`)) + // 测试使用独立临时根目录,不能继承开发服务的真实配置根目录。 + const env = { ...process.env, SMAIL_ROOT: temporary, GOTMPDIR: temporary, TMPDIR: temporary, TEMP: temporary, TMP: temporary } + const child = spawn(command, args, { cwd, env, windowsHide: true, stdio: ['inherit', 'pipe', 'pipe'] }) + child.stdout.on('data', data => { process.stdout.write(data); log.write(data) }) + child.stderr.on('data', data => { process.stderr.write(data); log.write(data) }) + const code = await new Promise(resolve => { + child.once('error', error => { console.error(`测试启动失败:${error.message}`); resolve(1) }) + child.once('close', value => resolve(value ?? 1)) + }) + await new Promise(resolve => log.end(resolve)) + return code +} + +let result = 0 +if (suite !== 'backend') { + const tests = (await readdir(path.join(root, 'test', 'frontend'))) + .filter(name => name.endsWith('.test.mjs')) + .map(name => path.join(root, 'test', 'frontend', name)) + if (!tests.length) throw new Error('没有找到前端测试,拒绝空测试通过') + result = await run('frontend', process.execPath, ['--test', ...tests], path.join(root, 'frontend')) +} +if (suite !== 'frontend' && result === 0) { + const args = extra[0] === '--' ? extra.slice(1) : extra + result = await run('backend', 'go', ['test', ...(args.length ? args : ['./...'])], path.join(root, 'backend')) +} +console.log(`测试记录:${path.relative(root, directory)}`) +process.exitCode = result diff --git a/test/tools/password-check/go.mod b/test/tools/password-check/go.mod new file mode 100644 index 00000000..1ca82118 --- /dev/null +++ b/test/tools/password-check/go.mod @@ -0,0 +1,12 @@ +module smail-test-password-check + +go 1.26.0 + +require git.pcux.cn/xuans/Smail v0.0.0 + +require ( + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) + +replace git.pcux.cn/xuans/Smail => ../../../backend diff --git a/test/tools/password-check/go.sum b/test/tools/password-check/go.sum new file mode 100644 index 00000000..d79e7a0a --- /dev/null +++ b/test/tools/password-check/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/test/tools/password-check/main.go b/test/tools/password-check/main.go new file mode 100644 index 00000000..67a9a1ec --- /dev/null +++ b/test/tools/password-check/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + "os" + + "git.pcux.cn/xuans/Smail/utils/password" +) + +func main() { + fmt.Print(password.Verify(os.Getenv("SMAIL_HASH"), os.Getenv("SMAIL_TEST_PASSWORD"))) +}