diff --git a/STATUS.md b/STATUS.md index ddcab95..d8a15e8 100644 --- a/STATUS.md +++ b/STATUS.md @@ -124,3 +124,64 @@ Landing/Dashboard/Repo/Admin/Settings、Clone 下拉、语言统计、依赖 Tab - `3111eb2` feat: discussions - `9ca4a0c` feat: batch 4 complete - `8d925d8` ... + +## 阶段 B0:前端 Nuxt 4 重构(进行中,2026-10-17) + +目标:`web-nuxt/` 以 **纯静态 SSG**(`ssr:false` + `nitro.preset=static`)并行重建前端, +保持单二进制 embed 与无 Node 运行时约束;`web/` 仍是权威产物,Go embed 未改动。 +详见 `web-nuxt/README.md`。 + +### 已完成的可用性修复(Windows 本机此前不可用) + +1. **服务在 Windows 起不来**:`enforcePerms` 用 POSIX 位判定数据目录,而 Windows 的 + `os.Stat` 返回合成模式(0777)→ 改走 `platform.CheckDataDirPerm`(权限位仅在 Unix 生效)。 +2. **`go test ./...` 在 Windows 必红(3 处)**: + - 集成测试把 OS 路径写进 TOML 基本字符串形成非法 `\U` 转义 → `tomlPath()` 走 `filepath.ToSlash`; + - `fsrepo.TestInstallHooks` 断言可执行位 → 用 `platform.Current().SupportsPerms` 守卫; + - `lfs.TestPutGetRoundtrip` 在 `Delete` 时仍持有读句柄 → 读后立即 `Close`(Linux 允许删除已打开文件,Windows 不允许)。 +3. **Git 托管在 Windows 全废**:`gitcmd.ValidateRepoPath` 无条件禁止 `\`,而仓库绝对路径必然带 + 反斜杠 → 所有 git 调用在进入 git 前就被拒。现只在非 Windows 分隔符平台上禁止 `\` 与 `~`, + 越界防护仍由 Clean + root 前缀包含检查承担;新增 `internal/infra/gitcmd/validate_test.go` 锁行为。 +4. **登录后 `isAdmin` 恒 false**(管理员被路由守卫挡在 `/admin` 外):`/auth/login` 改为返回 + `publicUser`(对 e2e 断言是纯增字段),前端登录响应类型诚实降级并以回读 `/user/me` 为准。 +5. **类型收口**:`RouteMeta` 声明合并、仓库页 Tab 变 union(URL 中文契约不变)、i18n 语言码 + union + 单一断言、`errMessage/apiStatus` 统一到 `utils/error.ts`,消除全部 `catch (e: any)`。 + +### 门禁证据 + +- `go vet ./...` = 0;`go test ./...` = 0(Windows 首次全绿);`go build` = 0; + `gofmt` 真实格式问题 0(本机 `core.autocrlf=true` 会让 `gofmt -l .` 误报全部 293 个文件, + 仓库 blob 是 LF,CI Linux 为权威)。 +- 旧前端:`vue-tsc --noEmit` = 0,`vite build` = 0。 +- 新前端:`nuxt typecheck` = 0(比旧配置更严,含 `noUncheckedIndexedAccess`,已修 6 处真实 + undefined 风险),`nuxt generate` = 0,产出 12 条路由 + `index.html`/`200.html` 同内容外壳。 +- 端到端冒烟(Nuxt dev 5174 → gitli 8080):SPA 深链接回退 200、`/api` 代理、CSRF 双提交 + (缺头 403)、Session Cookie、注册/登录(登录体 == `/user/me` 体,含 `is_admin`)、 + 建仓、分支列表 200、Issue 创建 201、评论 201、SSE `text/event-stream` 全部通过。 + +### 待办 + +阶段 3(进行中):自动化行为回归已覆盖暗色模式、Ctrl+K、`g i`/`g p`/`c`/`/` 快捷键、 +SSE 解析与退避重连、未读计数、i18n 切换、CSRF 头、登录后 `isAdmin`、Tab 契约、TopNav +管理入口、12 视图/页面壳导入契约 —— 共 **42 个用例全绿**(`cd web-nuxt && npm run verify`)。 +剩余:RepoView 各 Tab 数据流、Admin/UserSettings 面板交互、响应式抽查。 +阶段 4:切换 embed/Makefile/CI(Node 20 → 22/24)、清理旧目录、校正 README/AGENTS 中过时的 +技术栈与 `--config` 支持说明。 + +### 迁移期踩到的工程坑(已修,避免复发) + +1. `nuxt typecheck` 依赖 `.nuxt` 生成物,与 `nuxt prepare`/`postinstall` 并发会假红。 +2. npm 在 Windows 用 `cmd.exe` 解析 `&&` 会命中全局 `nuxt`,导致「单跑绿、链跑红」; + `npm run verify` 改走 `scripts/verify.mjs`,用仓库内 `node_modules` 具体入口顺序执行。 +3. 测试类型门禁最初形同虚设:`tests/` 不在 Nuxt 的 tsconfig include 内,故意写错的类型 + 不会被捕获。现由 `tests/tsconfig.json`(继承 `.nuxt`)覆盖,已用 probe 验证有效。 + +### 已知遗留(非本次引入) + +- 多个端点直接 marshal `domain` 结构体,JSON 是 PascalCase(`{"ID":1,"RepoID":1,...}`), + 与 `publicUser` 这类 snake_case DTO 并存;前端只能两边手抄。 +- ~~`docs/ARCHITECTURE.md` 与 `docs/architecture.md` 在大小写不敏感文件系统上互相踩踏。~~ + 已修复:删除重复的 `architecture.md`,只保留 `docs/ARCHITECTURE.md`,并更新 `docs/README.md` 链接。 +- `AGENTS.md §10`「所有子命令支持 `--config`」对 `gitli admin user create` 不成立(实测报 + `flag provided but not defined: -config`),README 另一处又说 `admin` 是保留命令,两处均过时。 + diff --git a/TODO.md b/TODO.md index 1145750..feb8f03 100644 --- a/TODO.md +++ b/TODO.md @@ -2,10 +2,49 @@ > 外部记忆文件。每次完成小模块就更新。上下文被压缩后先读本文件 + `ROADMAP.md` + `STATUS.md`。 -## 正在进行(阶段 A:功能冻结) -- [ ] 编写并跑通 test/e2e.sh -- [ ] 修复 E2E 失败项 -- [ ] 发布 v0.2.0-beta +## 正在进行(阶段 B0:前端 Nuxt 4 重构) + +### 可用性修复(已完成,Windows 本机) +- [x] `enforcePerms` 走 `platform.CheckDataDirPerm`(服务此前在 Windows 无法启动) +- [x] 集成测试 TOML 路径转义(`go test ./...` 在 Windows 恢复全绿) +- [x] `fsrepo` 钩子可执行位断言按平台能力守卫 +- [x] `lfs` 测试读句柄提前关闭(Windows 不允许删除已打开文件) +- [x] `gitcmd.ValidateRepoPath` 不再禁止原生分隔符(Windows Git 托管恢复)+ 新增 `validate_test.go` +- [x] `/auth/login` 返回 `publicUser`;前端登录以回读 `/user/me` 为准(管理员进 `/admin` 恢复) +- [x] RouteMeta 声明合并 / 仓库页 TabKey union / i18n 语言码 union / `utils/error.ts` 统一 +- [x] `errMessage` 全部收口:5 个视图统一到 `utils/error.ts`,`OrgSettingsView` 仅保留 403/404 专属文案并委托共享实现;`catch (e: any)` 归零 + +### Nuxt 4 脚手架(已完成) +- [x] `web-nuxt/`:`ssr:false` + `nitro.preset=static`,12 条路由逐一对齐 +- [x] `nuxt typecheck` 与 `nuxt generate` 全绿;产出 `index.html` + 同内容 `200.html` +- [x] Nuxt dev + 真实 gitli 端到端冒烟(CSRF/Session/注册登录/建仓/分支/Issue/评论/SSE) +- [x] `web-nuxt/README.md` 记录约束与切换前置条件 + +### 阶段 3:逐页行为回归(进行中) +- [x] 暗色模式、Ctrl+K 命令面板、`g i`/`g p`/`c`/`/` 快捷键 +- [x] SSE 事件解析、退避重连、未读计数(铃铛数据源) +- [x] i18n 中英切换(语言码 union + 两种语言同键解析) +- [x] CSRF 头注入与空 token 行为;登录后回读 `/user/me` 使 `isAdmin` 生效 +- [x] 仓库页 Tab 契约(顺序、拼错拒绝、URL 中文值不变) +- [x] TopNav:登录态分支、管理员「管理」入口、主题循环、⌘K 入口 +- [x] 12 视图导入契约 + 12 页面壳与旧 router 表一一对应 +- [x] 测试类型门禁(`tests/tsconfig.json` 继承 `.nuxt`,已用故意错误验证有效) +- [x] `npm run verify` 走 `scripts/verify.mjs`,规避 npm `&&` 链的全局 nuxt 命中 +- [ ] RepoView 各 Tab 数据流(代码/工单/合并请求/工作流/依赖/项目/讨论/发布/百科/活动/设置) +- [ ] AdminView / UserSettingsView 各面板交互 +- [ ] 响应式与无障碍抽查 + +### 阶段 4:切换(待做) +- [ ] `web/embed.go` 指向新产物(或把 `.output/public` 落到 embed 目录) +- [ ] `Makefile:17` 改为 Nuxt 构建 +- [ ] `ci.yml` / `cd.yml`:`node-version: '20'` → `22`/`24`(nuxt 4 engines) +- [ ] 旧 `web/src` 清理与 `web/dist` 提交策略确认 +- [ ] 校正 README/AGENTS/SECURITY 中过时条目(技术栈、`--config` 支持、发布版本、Markdown 渲染声明) + +## 已完成(阶段 A:功能冻结) +- [x] 编写并跑通 test/e2e.sh(PASS=34 FAIL=0 SKIP=2) +- [x] 修复 E2E 失败项 +- [x] 发布 v0.2.0-beta ## 企业级审计系统(用户单独要求,已完成) - [x] audit_logs 表扩展(actor/event/resource/target/old/new/result/request_id/metadata + 索引) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c57272a..6a8bb27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,7 @@ # 架构(ARCHITECTURE) -本文件是 gitli 架构的**权威文档**;`docs/architecture.md` 为兼容保留的精简版,如两者冲突,以本文件为准。 +本文件是 gitli 架构的**权威文档**(原 `docs/architecture.md` 精简副本已合并进本文件, +避免在大小写不敏感的文件系统上互相覆盖)。 gitli 是**单二进制、严格分层、依赖单向**的 Git 服务平台。 diff --git a/docs/README.md b/docs/README.md index 08d80d6..a9bb51c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,16 @@ # gitli 文档 - [架构(权威)](./ARCHITECTURE.md) -- [架构概览(精简)](./architecture.md) - [配置参考](./configuration.md) - [安全模型](./SECURITY.md) - [CI/CD 流水线](./CI.md) - [发布流程](./RELEASE.md) +- [审计日志](./AUDIT.md) +- [安全管理](./ADMIN.md) +- [用户手册](./USER.md) + +> 说明:历史上曾有 `docs/architecture.md`(精简副本)与 `docs/ARCHITECTURE.md`(权威版)两份, +> 在大小写不敏感的文件系统(Windows/macOS)上会互相覆盖。现已合并为唯一的 +> `docs/ARCHITECTURE.md`。 更多文档随功能推进逐步补充。 diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index 0517327..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,41 +0,0 @@ -# 架构概览 - -gitli 采用严格分层的单二进制架构。 - -``` -main.go - └── internal/app 应用装配与生命周期 - ├── internal/transport/{http,ssh,sse} 协议接入 - ├── internal/service 业务逻辑 - └── internal/infra 基础设施 -``` - -## 分层职责 - -| 层 | 职责 | 依赖 | -| --- | --- | --- | -| `domain` | 纯领域结构体 | 仅标准库 | -| `infra` | git 子进程、文件系统、队列、加密、密码、Token | domain | -| `service` | 业务用例 | domain、infra、config | -| `transport` | HTTP / SSH / SSE 协议 | service、domain | -| `app` | 装配、启动、优雅退出 | 全部 | - -依赖方向严格单向,禁止反向或循环依赖。详见 [`AGENTS.md`](../AGENTS.md)。 - -## 单二进制 - -- 前端构建产物位于 `web/dist`,通过 `//go:embed` 嵌入。 -- 数据库迁移位于 `internal/migrations/{sqlite,mysql,postgres}`,同样 embed。 -- `go build -o gitli .` 产出唯一可执行文件。 - -## 数据库抽象 - -- 业务层只依赖 `database/sql`。 -- 方言差异通过 `internal/db` 的 `Dialect` 与占位符重写(`?` ↔ `$1`)统一。 -- 迁移脚本按方言分目录维护。 - -## 低内存策略 - -- Git / 打包 / CI 等重任务按需启动子进程,并以并发信号量限流。 -- 大文件上传下载全程流式(`io.Copy` / `io.Pipe`)。 -- 空闲内存目标 < 150 MB。 diff --git a/internal/app/app.go b/internal/app/app.go index 41cac0e..e25c840 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -19,8 +19,8 @@ import ( "github.com/LiStudioorg/gitli/internal/infra/ldapauth" "github.com/LiStudioorg/gitli/internal/infra/queue" "github.com/LiStudioorg/gitli/internal/infra/samlauth" - "github.com/LiStudioorg/gitli/internal/infra/secutil" "github.com/LiStudioorg/gitli/internal/infra/weakpass" + "github.com/LiStudioorg/gitli/internal/platform" "github.com/LiStudioorg/gitli/internal/service/activity" "github.com/LiStudioorg/gitli/internal/service/audit" "github.com/LiStudioorg/gitli/internal/service/auth" @@ -249,7 +249,14 @@ func New(ctx context.Context, cfg *config.Config, configPath ...string) (*App, e } func enforcePerms(cfg *config.Config) error { - return secutil.EnforceDirPerms(map[string]os.FileMode{ - cfg.Storage.Repos: 0o750, - }) + if cfg.Storage.Repos == "" { + return nil + } + if _, err := os.Stat(cfg.Storage.Repos); err != nil { + return nil + } + // 权限位只在 Unix 上有意义:Windows 的 os.Stat 返回合成模式(临时目录常为 + // 0777),直接按 POSIX 位判定会让服务在本机完全起不来。平台差异交给 + // internal/platform 吸收,符合 AGENTS「文件权限检查仅在 Unix 生效」。 + return platform.CheckDataDirPerm(cfg.Storage.Repos, 0o750) } diff --git a/internal/infra/fsrepo/hooks_test.go b/internal/infra/fsrepo/hooks_test.go index 0f54241..7d8fff7 100644 --- a/internal/infra/fsrepo/hooks_test.go +++ b/internal/infra/fsrepo/hooks_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/LiStudioorg/gitli/internal/platform" ) func TestInstallHooks(t *testing.T) { @@ -15,6 +17,9 @@ func TestInstallHooks(t *testing.T) { t.Fatal(err) } l := New(dir) + // 可执行位只在 POSIX 平台有意义:Windows 上 os.Chmod 不会产生 0111。 + // AGENTS 约定「文件权限检查仅在 Unix 生效」,这里按平台能力决定是否断言。 + checkExec := platform.Current().SupportsPerms if err := l.InstallHooks(repoPath, "alice", "demo", "/usr/local/bin/gitli", "/etc/gitli/config.toml"); err != nil { t.Fatal(err) } @@ -24,7 +29,7 @@ func TestInstallHooks(t *testing.T) { if err != nil { t.Fatalf("hook %s missing: %v", name, err) } - if info.Mode().Perm()&0o111 == 0 { + if checkExec && info.Mode().Perm()&0o111 == 0 { t.Fatalf("hook %s not executable", name) } data, _ := os.ReadFile(path) diff --git a/internal/infra/gitcmd/resolve.go b/internal/infra/gitcmd/resolve.go index d459178..be64fae 100644 --- a/internal/infra/gitcmd/resolve.go +++ b/internal/infra/gitcmd/resolve.go @@ -77,7 +77,9 @@ func resolveOnce(bin string) (string, error) { if info.IsDir() { return "", fmt.Errorf("gitcmd: %s is a directory", bin) } - if info.Mode().Perm()&0o111 == 0 { + // 可执行位只在 POSIX 平台有意义:Windows 的 os.Stat 不返回 0111, + // 无条件检查会让显式配置 [git] bin 的实例直接拒绝启动。 + if runtime.GOOS != "windows" && info.Mode().Perm()&0o111 == 0 { return "", fmt.Errorf("gitcmd: %s is not executable", bin) } abs, err := filepath.Abs(bin) diff --git a/internal/infra/gitcmd/resolve_test.go b/internal/infra/gitcmd/resolve_test.go index d216b3f..a6d57a0 100644 --- a/internal/infra/gitcmd/resolve_test.go +++ b/internal/infra/gitcmd/resolve_test.go @@ -4,6 +4,7 @@ package gitcmd import ( "os" "path/filepath" + "runtime" "testing" ) @@ -38,3 +39,19 @@ func TestFindTermuxGitMissing(t *testing.T) { t.Skip("host actually has a Termux git path") } } + +// TestResolveNonExecutableOnPOSIX 锁住「放宽 Windows 可执行位检查」没有把 +// POSIX 侧的防护一起放宽:非可执行文件仍必须被拒绝。 +func TestResolveNonExecutableOnPOSIX(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows 没有 POSIX 可执行位,本检查不适用") + } + dir := t.TempDir() + bin := filepath.Join(dir, "git") + if err := os.WriteFile(bin, []byte("#!/bin/sh\nexit 0\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := resolveOnce(bin); err == nil { + t.Fatal("expected non-executable bin to be rejected") + } +} diff --git a/internal/infra/gitcmd/validate.go b/internal/infra/gitcmd/validate.go index 6ec408f..3a3ea6a 100644 --- a/internal/infra/gitcmd/validate.go +++ b/internal/infra/gitcmd/validate.go @@ -3,6 +3,7 @@ package gitcmd import ( "fmt" + "os" "path/filepath" "strings" ) @@ -16,7 +17,14 @@ func ValidateRepoPath(root, repoPath string) (string, error) { if strings.ContainsRune(repoPath, 0) { return "", fmt.Errorf("gitcmd: repo path contains NUL byte") } - if strings.Contains(repoPath, "..") || strings.ContainsAny(repoPath, "~\\") { + if strings.Contains(repoPath, "..") { + return "", fmt.Errorf("gitcmd: repo path contains forbidden characters") + } + // 反斜杠与 ~ 只在 POSIX 上才是可疑字符。Windows 上 \ 是原生路径分隔符, + // 一律禁止会让本机所有 git 操作(分支、文件树、clone/push)全部失败, + // 与 README 宣称的 windows 支持矩阵矛盾。真正的越界防护是下面的 + // Clean 之后的 root 前缀包含检查,owner/repo 名字另由 secutil.ValidateName 过滤。 + if os.PathSeparator != '\\' && strings.ContainsAny(repoPath, "~\\") { return "", fmt.Errorf("gitcmd: repo path contains forbidden characters") } cleanRoot := filepath.Clean(root) diff --git a/internal/infra/gitcmd/validate_test.go b/internal/infra/gitcmd/validate_test.go new file mode 100644 index 0000000..6c7ff01 --- /dev/null +++ b/internal/infra/gitcmd/validate_test.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0 +package gitcmd + +import ( + "os" + "path/filepath" + "testing" +) + +// Layout.Path 的返回值必然带平台原生分隔符。此前 ValidateRepoPath 无条件禁止 +// '\',Windows 上任何仓库路径都会在进入 git 之前被判为非法,等于 Git 托管在该 +// 平台整体不可用;这里锁住「原生路径必须放行」这条行为。 +func TestValidateRepoPathAcceptsNativeLayout(t *testing.T) { + root := filepath.Join(t.TempDir(), "repos") + if err := os.MkdirAll(root, 0o750); err != nil { + t.Fatal(err) + } + p := filepath.Join(root, "alice", "demo.git") + got, err := ValidateRepoPath(root, p) + if err != nil { + t.Fatalf("native repo path rejected: %v", err) + } + if want := filepath.Clean(p); got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestValidateRepoPathRejectsEscapeAttempts(t *testing.T) { + root := filepath.Join(t.TempDir(), "repos") + cases := []struct { + name string + path string + }{ + {"empty", ""}, + {"root itself", root}, + // filepath.Join 会先把 ".." 化简掉,越界靠 Clean + root 前缀检查兜住。 + {"joined escape", filepath.Join(root, "..", "outside")}, + {"literal traversal", root + string(filepath.Separator) + ".." + string(filepath.Separator) + "outside"}, + {"nul byte", filepath.Join(root, "a\x00b")}, + } + for _, c := range cases { + if _, err := ValidateRepoPath(root, c.path); err == nil { + t.Errorf("%s: expected rejection", c.name) + } + } +} + +// 反斜杠与 ~ 作为「可疑字符」只在 POSIX 上有意义:Windows 上它们是合法分隔符 / +// 短文件名组成部分。该用例保证这次放宽不会把 POSIX 侧的防护一起放宽。 +func TestValidateRepoPathSuspiciousCharsArePOSIXOnly(t *testing.T) { + root := filepath.Join(t.TempDir(), "repos") + if os.PathSeparator == '\\' { + return + } + if _, err := ValidateRepoPath(root, filepath.Join(root, "demo")+"\\evil"); err == nil { + t.Error("posix: backslash must be rejected") + } + if _, err := ValidateRepoPath(root, filepath.Join(root, "~home")); err == nil { + t.Error("posix: tilde must be rejected") + } +} diff --git a/internal/infra/secutil/secutil.go b/internal/infra/secutil/secutil.go index 0b60b89..a9ef641 100644 --- a/internal/infra/secutil/secutil.go +++ b/internal/infra/secutil/secutil.go @@ -117,6 +117,9 @@ func EnsureWithin(base, target string) (string, error) { } // CheckPerms fails if path's permission bits are looser than want. +// +// 平台无关的 POSIX 位判定在 Windows 上不可靠(os.Stat 返回合成模式), +// 需要目录权限校验请走 internal/platform.CheckDataDirPerm。 func CheckPerms(path string, want os.FileMode) error { info, err := os.Stat(path) if err != nil { diff --git a/internal/service/lfs/lfs_test.go b/internal/service/lfs/lfs_test.go index be2607f..23233e6 100644 --- a/internal/service/lfs/lfs_test.go +++ b/internal/service/lfs/lfs_test.go @@ -91,11 +91,15 @@ func TestPutGetRoundtrip(t *testing.T) { if err != nil { t.Fatalf("get: %v", err) } - defer func() { _ = rc.Close() }() + // 句柄必须在 Delete 之前显式关闭:Windows 不允许删除仍被打开的文件, + // defer 到测试结束会让 os.Remove 报 "being used by another process"。 + closeRC := func() { _ = rc.Close() } if size != int64(len(payload)) { + closeRC() t.Fatalf("size = %d want %d", size, len(payload)) } got, err := io.ReadAll(rc) + closeRC() if err != nil { t.Fatalf("read: %v", err) } diff --git a/internal/transport/http/handlers_auth.go b/internal/transport/http/handlers_auth.go index f396c91..fad2d22 100644 --- a/internal/transport/http/handlers_auth.go +++ b/internal/transport/http/handlers_auth.go @@ -79,7 +79,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { Secure: s.deps.HTTPSecure(), MaxAge: 30 * 24 * 3600, }) - writeJSON(w, http.StatusOK, map[string]any{"id": res.User.ID, "username": res.User.Username}) + // 登录响应必须是完整公开档案:前端 auth store 直接把响应体写入 user 状态并置 + // loaded=true,此后路由守卫不再回读 /user/me。若此处只返回 id/username, + // is_admin 缺失会让管理员登录后被挡在 /admin 之外。 + writeJSON(w, http.StatusOK, publicUser(res.User)) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { diff --git a/test/integration_test.go b/test/integration_test.go index 3002c52..32ace80 100644 --- a/test/integration_test.go +++ b/test/integration_test.go @@ -4,12 +4,8 @@ // // These tests exercise real components (config loading, database migration, // RBAC seeding and git repository provisioning) without spinning up the HTTP -// listener. They are guarded by a build tag so that plain `go test ./...` -// skips them and does not depend on a local git binary or writable temp state. -// -// Run with: -// -// go test -tags integration ./test/ +// listener. They run as part of plain `go test ./...` and require a writable +// temp dir plus a local git binary. // // SHA-256 is stubbed in the sandbox, so no token/hash/crypto value is asserted; // only structural facts (row counts, filesystem existence, RBAC evaluation) @@ -53,18 +49,22 @@ level = "error" format = "text" ` +// tomlPath renders an OS path for use inside a TOML basic string: on Windows +// the backslashes would otherwise form invalid escapes such as "\Users". +func tomlPath(p string) string { return filepath.ToSlash(p) } + func TestEndToEnd(t *testing.T) { ctx := context.Background() root := t.TempDir() cfgPath := filepath.Join(root, "config.toml") body := fmt.Sprintf(testConfig, - filepath.Join(root, "gitli.db"), - filepath.Join(root, "repos"), - filepath.Join(root, "lfs"), - filepath.Join(root, "packages"), - filepath.Join(root, "avatars"), - filepath.Join(root, "attachments"), + tomlPath(filepath.Join(root, "gitli.db")), + tomlPath(filepath.Join(root, "repos")), + tomlPath(filepath.Join(root, "lfs")), + tomlPath(filepath.Join(root, "packages")), + tomlPath(filepath.Join(root, "avatars")), + tomlPath(filepath.Join(root, "attachments")), ) if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) diff --git a/web-nuxt/.gitignore b/web-nuxt/.gitignore new file mode 100644 index 0000000..05c634c --- /dev/null +++ b/web-nuxt/.gitignore @@ -0,0 +1,7 @@ +node_modules +.nuxt +.output +.data +.cache +dist +*.log diff --git a/web-nuxt/README.md b/web-nuxt/README.md new file mode 100644 index 0000000..0d9f171 --- /dev/null +++ b/web-nuxt/README.md @@ -0,0 +1,77 @@ +# gitli Web(Nuxt 4 重构中) + +这个目录是 gitli 前端的**下一代实现**,与仍在生效的 `web/`(Vite + Vue 3)**并行存在**。 +在阶段 4 切换之前,`web/` 才是权威产物:Go 仍然 `//go:embed all:dist` 嵌入 `web/dist`, +本目录的任何产物都不会进入二进制。 + +## 为什么必须是纯静态 + +AGENTS.md 的核心约束不可违背:单二进制、无外部强依赖、空闲内存 < 150 MB。 +Nuxt 4 的默认形态是 Node SSR 服务,那会引入一个常驻 Node 进程。因此这里锁定: + +- `ssr: false` —— 只产出客户端 SPA,不做服务端渲染; +- `nitro.preset = 'static'` —— 不生成 Nitro 运行时,产物是纯文件; +- `npm run build` = `nuxt generate` —— 输出 `.output/public/`。 + +`generate` 会同时产出 `index.html` 与内容一致的 `200.html`,因此现有 +`web/embed.go` 的「未知路径回退 index.html」在切换时**零 Go 改动**即可支撑 +`/owner/repo`、`/users/x` 这些无法预渲染的动态路由。 + +## 本地跑起来 + +```bash +cd web-nuxt +npm install # 需要 Node >= 22.12(nuxt 4.6 的 engines) +npm run dev # http://localhost:5174,/api 代理到 127.0.0.1:8080 +npm run generate # 静态产物:.output/public +npm run typecheck # nuxt typecheck(比旧前端更严:含 noUncheckedIndexedAccess) +npm run verify # typecheck + 测试类型门禁 + 42 个行为测试 +npm test # 只跑行为测试(tests/**) +``` + +### 测试分层 + +| 层 | 位置 | 覆盖 | +| --- | --- | --- | +| 行为单测 | `tests/*.test.ts`(42 用例) | 主题循环、快捷键 `g i`/`g p`/`c`/`/`、Ctrl+K 面板、SSE 解析与退避重连、未读计数、i18n 语言切换、CSRF 头、登录回读 `/user/me`、Tab 契约、TopNav 管理入口可见性、12 视图与页面壳导入契约 | +| 类型门禁 | `tests/tsconfig.json`(继承 `.nuxt`)+ `tsc -p` | 测试代码本身也在类型检查内(曾发现它形同虚设,见下) | +| 端到端 | 手工脚本 | Nuxt dev(5174) → 真实 gitli(8080):SPA 回退、代理、CSRF、Session、注册登录、建仓、Issue/评论、SSE | + +> 两个踩过的坑,避免重犯: +> 1. `nuxt typecheck` 依赖 `.nuxt` 生成物,与 `nuxt prepare`/`postinstall` 并发会假红。 +> 2. npm 在 Windows 用 `cmd.exe` 解析 `&&` 链时可能命中全局 `nuxt`。因此 `npm run verify` +> 走 `scripts/verify.mjs`,用本仓库 `node_modules` 的具体入口顺序执行。 + +后端另开一个实例即可:`./gitli serve --config config.toml`(默认监听 `:8080`)。 + +## 与旧前端的对应关系 + +| 旧 `web/src` | 新 `web-nuxt/app` | 说明 | +| --- | --- | --- | +| `router/index.ts`(12 条路由) | `pages/**`(12 个薄壳) | 路径逐条对齐;页面壳只负责 `definePageMeta` 与导入视图 | +| `views/*.vue` | `views/*.vue` | **原样复制**,放在 `app/views/` 以保持相对导入深度不变 | +| `components/`、`composables/`、`stores/`、`api/`、`utils/`、`constants/`、`locales/`、`i18n.ts`、`style.css` | 同名同层级 | 原样复制,避免迁移期两份实现行为漂移 | +| `router.beforeEach` 守卫 | `middleware/auth.global.ts` | `requiresAuth` / `requiresAdmin` / `guestOnly` 语义逐条对应 | +| `createApp().use(pinia)` | `@pinia/nuxt` | options store 写法不变 | +| `createApp().use(i18n)` | `plugins/i18n.client.ts` | 仍用 vue-i18n **9.14.5**(与旧前端同版本,避免行为漂移),不引入 `@nuxtjs/i18n` | + +## 当前状态(阶段 3 进行中) + +- `nuxt typecheck`、`npm run verify`(42 个行为测试)、`nuxt generate` 均通过;12 条路由全部产出。 +- 已用「Nuxt dev(5174)+ 真实 gitli(8080)」做过端到端冒烟:SPA 外壳与深链接回退、 + `/api` 代理、CSRF 双提交、Session Cookie、注册/登录(登录体与 `/user/me` 一致)、 + 建仓、Issue/评论(201)、SSE `text/event-stream` 全部走通。 +- 自动化行为回归已覆盖:暗色模式、Ctrl+K 命令面板、`g i`/`g p`/`c`/`/` 快捷键、 + SSE 解析与退避重连 + 未读计数、i18n 中英切换、CSRF 头注入、登录后 `isAdmin`、 + 仓库页 Tab 契约、TopNav 管理入口、12 视图/页面壳导入契约。 +- 尚未做:RepoView 各 Tab 的数据流回归、AdminView/UserSettingsView 各面板交互回归、 + 响应式与无障碍抽查。 + +## 切换前必须一起改的外部条件 + +1. `.github/workflows/ci.yml` 与 `cd.yml` 目前钉 `node-version: '20'`,nuxt 4 要求 + `^22.22.3 || ^24.15.0 || >=26.0.0`,必须同步抬版本,否则 Release 直接失败。 +2. `Makefile:17` 的 `cd web && npm install && npm run build` 需要换成 Nuxt 构建并把 + `.output/public` 落到 embed 目录。 +3. `web/dist` 是提交进 git 的产物(让 `go build` 不依赖 Node);切换时要决定 Nuxt 产物 + 的落盘位置与是否继续提交。 diff --git a/web-nuxt/app/api/client.ts b/web-nuxt/app/api/client.ts new file mode 100644 index 0000000..e96af91 --- /dev/null +++ b/web-nuxt/app/api/client.ts @@ -0,0 +1,35 @@ +import axios from 'axios' + +function readCookie(name: string): string { + const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')) + const raw = match?.[1] + return raw ? decodeURIComponent(raw) : '' +} + +const api = axios.create({ + baseURL: '/api/v1', + withCredentials: true, +}) + +api.interceptors.request.use((config) => { + const method = (config.method ?? 'get').toLowerCase() + if (method !== 'get' && method !== 'head') { + config.headers = config.headers ?? {} + config.headers['X-CSRF-Token'] = readCookie('gitli_csrf') + } + return config +}) + +api.interceptors.response.use( + (res) => res, + (err) => { + if (err?.response?.status === 412 && err.response.data?.code === 'setup_required') { + if (location.pathname !== '/setup') { + location.assign('/setup') + } + } + return Promise.reject(err) + }, +) + +export default api diff --git a/web-nuxt/app/app.vue b/web-nuxt/app/app.vue new file mode 100644 index 0000000..c9c48e9 --- /dev/null +++ b/web-nuxt/app/app.vue @@ -0,0 +1,12 @@ + + + diff --git a/web-nuxt/app/components/common/ActivityFeed.vue b/web-nuxt/app/components/common/ActivityFeed.vue new file mode 100644 index 0000000..5ef3cc4 --- /dev/null +++ b/web-nuxt/app/components/common/ActivityFeed.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/web-nuxt/app/components/common/ActivityItem.vue b/web-nuxt/app/components/common/ActivityItem.vue new file mode 100644 index 0000000..04a08fd --- /dev/null +++ b/web-nuxt/app/components/common/ActivityItem.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/web-nuxt/app/components/common/CommandPalette.vue b/web-nuxt/app/components/common/CommandPalette.vue new file mode 100644 index 0000000..1aa5406 --- /dev/null +++ b/web-nuxt/app/components/common/CommandPalette.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/web-nuxt/app/components/common/ContributionGraph.vue b/web-nuxt/app/components/common/ContributionGraph.vue new file mode 100644 index 0000000..12aa493 --- /dev/null +++ b/web-nuxt/app/components/common/ContributionGraph.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/web-nuxt/app/components/common/FeatureCard.vue b/web-nuxt/app/components/common/FeatureCard.vue new file mode 100644 index 0000000..71ca725 --- /dev/null +++ b/web-nuxt/app/components/common/FeatureCard.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/web-nuxt/app/components/common/MarkdownView.vue b/web-nuxt/app/components/common/MarkdownView.vue new file mode 100644 index 0000000..c4507b6 --- /dev/null +++ b/web-nuxt/app/components/common/MarkdownView.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/web-nuxt/app/components/common/RepoSidebarCard.vue b/web-nuxt/app/components/common/RepoSidebarCard.vue new file mode 100644 index 0000000..24638c1 --- /dev/null +++ b/web-nuxt/app/components/common/RepoSidebarCard.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/web-nuxt/app/components/common/SavedReplies.vue b/web-nuxt/app/components/common/SavedReplies.vue new file mode 100644 index 0000000..b4c0b0f --- /dev/null +++ b/web-nuxt/app/components/common/SavedReplies.vue @@ -0,0 +1,349 @@ + + +