Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 是保留命令,两处均过时。

47 changes: 43 additions & 4 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 + 索引)
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# 架构(ARCHITECTURE)

本文件是 gitli 架构的**权威文档**;`docs/architecture.md` 为兼容保留的精简版,如两者冲突,以本文件为准。
本文件是 gitli 架构的**权威文档**(原 `docs/architecture.md` 精简副本已合并进本文件,
避免在大小写不敏感的文件系统上互相覆盖)。

gitli 是**单二进制、严格分层、依赖单向**的 Git 服务平台。

Expand Down
8 changes: 7 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
@@ -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`。

更多文档随功能推进逐步补充。
41 changes: 0 additions & 41 deletions docs/architecture.md

This file was deleted.

15 changes: 11 additions & 4 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
7 changes: 6 additions & 1 deletion internal/infra/fsrepo/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/LiStudioorg/gitli/internal/platform"
)

func TestInstallHooks(t *testing.T) {
Expand All @@ -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)
}
Expand All @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion internal/infra/gitcmd/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions internal/infra/gitcmd/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package gitcmd
import (
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -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")
}
}
10 changes: 9 additions & 1 deletion internal/infra/gitcmd/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gitcmd

import (
"fmt"
"os"
"path/filepath"
"strings"
)
Expand All @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions internal/infra/gitcmd/validate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
3 changes: 3 additions & 0 deletions internal/infra/secutil/secutil.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion internal/service/lfs/lfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading