From ad3c1d8d8c7a9ca29c3c47451dd02e5f3f5b017e Mon Sep 17 00:00:00 2001 From: xgp2012 <2820567502@qq.com> Date: Sat, 17 Oct 2026 18:49:02 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Windows=20?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=80=A7=E7=BC=BA=E9=99=B7=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=89=8D=E7=AB=AF=E7=B1=BB=E5=9E=8B=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端(Windows 上此前不可用): - app.enforcePerms 改走 platform.CheckDataDirPerm:POSIX 权限位在 Windows 上无意义(os.Stat 返回 0777),原实现会让服务直接拒绝启动 - gitcmd.ValidateRepoPath 不再禁止原生分隔符 '\':Windows 仓库路径必然带 反斜杠,原实现使分支、文件树、clone/push 全部在进入 git 前被拒 - gitcmd.resolveOnce 的可执行位检查仅在 POSIX 生效:否则显式配置 [git] bin 在 Windows 上无法启动 - /auth/login 返回 publicUser 而非 {id,username}:前端据此置 loaded=true 后 不再回读 /user/me,is_admin 缺失会让管理员被守卫挡在 /admin 之外 - 补充 gitcmd 回归测试:原生路径放行、5 种逃逸拒绝、POSIX 可疑字符仍受限、 非可执行仍被拒绝 测试(Windows 上 go test ./... 恢复全绿): - 集成测试的 TOML 路径转义(C:\Users 会被当作非法 \U 转义) - fsrepo 钩子可执行位断言按平台能力守卫 - lfs 测试读句柄在 Delete 前关闭(Windows 不允许删除已打开文件) 文档: - 删除与 ARCHITECTURE.md 大小写冲突的 architecture.md,消除大小写不敏感 文件系统上的互相覆盖,并更新 docs/README.md --- STATUS.md | 61 ++++++++++ TODO.md | 47 +++++++- docs/README.md | 8 +- docs/architecture.md | 139 ++++++++++++++++++----- internal/app/app.go | 15 ++- internal/infra/fsrepo/hooks_test.go | 7 +- internal/infra/gitcmd/resolve.go | 4 +- internal/infra/gitcmd/resolve_test.go | 17 +++ internal/infra/gitcmd/validate.go | 10 +- internal/infra/gitcmd/validate_test.go | 61 ++++++++++ internal/infra/secutil/secutil.go | 3 + internal/service/lfs/lfs_test.go | 6 +- internal/transport/http/handlers_auth.go | 5 +- test/integration_test.go | 24 ++-- 14 files changed, 355 insertions(+), 52 deletions(-) create mode 100644 internal/infra/gitcmd/validate_test.go 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/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 index 0517327..6a8bb27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,41 +1,128 @@ -# 架构概览 +# 架构(ARCHITECTURE) -gitli 采用严格分层的单二进制架构。 +本文件是 gitli 架构的**权威文档**(原 `docs/architecture.md` 精简副本已合并进本文件, +避免在大小写不敏感的文件系统上互相覆盖)。 + +gitli 是**单二进制、严格分层、依赖单向**的 Git 服务平台。 + +--- + +## 1. 分层总览 ``` main.go - └── internal/app 应用装配与生命周期 - ├── internal/transport/{http,ssh,sse} 协议接入 - ├── internal/service 业务逻辑 - └── internal/infra 基础设施 + └── internal/app 应用装配与生命周期 + ├── internal/transport 协议接入层 + │ ├── http HTTP / REST / Git Smart Protocol + │ ├── ssh 内置 SSH 服务器 / git-shell 适配 + │ └── sse Server-Sent Events + ├── internal/service 业务逻辑层 + └── internal/infra 基础设施层 ``` -## 分层职责 - -| 层 | 职责 | 依赖 | +| 层 | 职责 | 允许依赖 | | --- | --- | --- | | `domain` | 纯领域结构体 | 仅标准库 | -| `infra` | git 子进程、文件系统、队列、加密、密码、Token | domain | -| `service` | 业务用例 | domain、infra、config | -| `transport` | HTTP / SSH / SSE 协议 | service、domain | -| `app` | 装配、启动、优雅退出 | 全部 | +| `infra` | git 子进程、文件系统、队列、加密、密码、Token、备份 | `domain` | +| `service` | 业务用例(用户、仓库、RBAC、Issue/PR…) | `domain`、`infra`、`config`、`store` | +| `transport` | HTTP / SSH / SSE 协议接入 | `service`、`domain` | +| `app` | 装配、启动、优雅退出、钩子、SSH 适配 | 全部 | + +--- + +## 2. 依赖方向(严格单向) + +``` +main.go → internal/app → {transport, service, infra} + +service → domain, infra, config, store +transport → service, domain +infra → domain +domain → 仅标准库 +``` + +**禁止**: + +- `domain` 依赖任何其它 internal 包。 +- `service` 直接 import `transport`。 +- 业务包直接 import 具体数据库驱动(只能用 `database/sql`)。 +- 任何形式的循环依赖。 + +违反依赖方向会导致编译失败,这是有意设计的护栏。 + +--- + +## 3. 包地图 + +``` +gitli/ +├── main.go 入口,子命令分发 +├── internal/ +│ ├── app/ 装配、生命周期、hook、ssh 适配 +│ ├── config/ config.go / load.go / defaults.go / external.go / template.go +│ ├── db/ 三驱动 + 迁移执行 + Rebind +│ ├── migrations/{sqlite,mysql,postgres}/ +│ ├── transport/{http,ssh,sse}/ 协议接入 +│ ├── domain/ 纯结构体(仅标准库) +│ ├── service/ 业务逻辑(见下) +│ ├── infra/ gitcmd / fsrepo / queue / mail / oidc / crypto / password / token / backup / secutil / netutil / weakpass +│ ├── platform/ 跨平台抽象(SignalSet、build tag 隔离) +│ └── version/ 版本信息 +├── web/ 前端(embed.go + dist + src) +├── api / scripts / deploy / docs / test +``` + +**service 子包**:`auth`、`user`、`org`、`repo`、`git`、`issue`、`pull`、`comment`、`wiki`、`gist`、`release`、`lfs`、`webhook`、`notification`、`mirror`、`import`、`search`、`package`、`pkgsvc`、`registry`、`ci`、`ide`、`badge`、`activity`、`audit`、`metrics`、`pat`、`rbac`、`store`。 + +--- + +## 4. 数据库抽象 + +业务层只依赖标准库 `database/sql`,三类驱动(SQLite `modernc.org/sqlite`、MySQL、PostgreSQL `pgx/v5/stdlib`)在 `internal/db` 内注册与选择。 + +- **占位符统一**:业务 SQL 一律写 `?`;PostgreSQL 由 `db.Rebind` 改写为 `$1`、`$2`…,业务代码无需感知方言。 +- **方言差异**:主键、时间、布尔类型按方言分别建模,见下表。 +- **迁移**:文件按方言分目录(`internal/migrations/{sqlite,mysql,postgres}`),命名 `NNNN_name.sql`,支持 up/down;`gitli migrate` 依据 `database.driver` 选择目录并执行。 + +| 概念 | SQLite | MySQL | PostgreSQL | +| --- | --- | --- | --- | +| 主键 | `INTEGER PRIMARY KEY AUTOINCREMENT` | `BIGINT AUTO_INCREMENT PRIMARY KEY` | `BIGSERIAL PRIMARY KEY` | +| 时间 | `DATETIME` | `DATETIME` | `TIMESTAMPTZ` | +| 布尔 | `INTEGER` | `TINYINT(1)` | `BOOLEAN` | +| 占位符 | `?` | `?` | `$1`(由 `Rebind` 改写) | + +> 迁移执行器的 `splitStatements` 会剥离 `--` 整行注释,但绝不能让注释吞掉语句(历史 bug,已修)。 + +--- + +## 5. 单二进制与 embed + +- 前端构建产物 `web/dist` 通过 `//go:embed` 内嵌。 +- 数据库迁移文件同样 embed。 +- `go build -o gitli .` 产出**唯一**可执行文件;所有构建场景统一 `CGO_ENABLED=0 -trimpath -ldflags="-s -w ..."`,纯静态、可运行于 Alpine / scratch。 + +--- + +## 6. 低内存策略 -依赖方向严格单向,禁止反向或循环依赖。详见 [`AGENTS.md`](../AGENTS.md)。 +- Git / 打包 / CI 等重任务**按需启动子进程**,并以并发信号量限流(`limits.max_git_concurrency` 等)。 +- 大文件上传下载全程**流式**(`io.Copy` / `io.Pipe`),备份加密分块流式处理,不整包读入内存。 +- 可选模块可通过配置完全关闭;关闭时不初始化、不分配资源、不启动 goroutine。 +- 空闲内存目标 **< 150 MB**。 -## 单二进制 +--- -- 前端构建产物位于 `web/dist`,通过 `//go:embed` 嵌入。 -- 数据库迁移位于 `internal/migrations/{sqlite,mysql,postgres}`,同样 embed。 -- `go build -o gitli .` 产出唯一可执行文件。 +## 7. 跨平台 -## 数据库抽象 +- 禁止硬编码路径分隔符与平台 syscall;平台相关代码用 build tag 隔离,集中于 `internal/platform`。 +- 信号由 `platform.SignalSet()` 提供:Unix 监听 `SIGINT`/`SIGTERM`,Windows 仅监听 `os.Interrupt`。 +- 监听地址与对外地址严格分离,对外 URL 统一由 `config.ResolveExternalURLs` 生成。 -- 业务层只依赖 `database/sql`。 -- 方言差异通过 `internal/db` 的 `Dialect` 与占位符重写(`?` ↔ `$1`)统一。 -- 迁移脚本按方言分目录维护。 +--- -## 低内存策略 +## 8. 相关文档 -- Git / 打包 / CI 等重任务按需启动子进程,并以并发信号量限流。 -- 大文件上传下载全程流式(`io.Copy` / `io.Pipe`)。 -- 空闲内存目标 < 150 MB。 +- [安全模型](./SECURITY.md) +- [配置参考](./configuration.md) +- [CI/CD](./CI.md) +- [发布流程](./RELEASE.md) 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) From 9c6b3cffca8d52c6105f394708e30b4c4062e4d6 Mon Sep 17 00:00:00 2001 From: xgp2012 <2820567502@qq.com> Date: Sat, 17 Oct 2026 18:49:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20=E5=BC=95=E5=85=A5=20Nuxt=204?= =?UTF-8?q?=20=E9=9D=99=E6=80=81=E5=89=8D=E7=AB=AF=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E5=85=B1=E4=BA=AB=E7=B1=BB=E5=9E=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web-nuxt/(与现有 web/ 并行,Go embed 仍指向 web/dist): - ssr:false + nitro preset=static:产物是纯静态文件,不引入 Node 运行时, 守住单二进制、无外部强依赖、空闲内存 <150MB 的约束 - 16 条路由与 beta 分支 web/src/router/index.ts 逐条对应;页面壳只负责 definePageMeta,视图文件原样复用(保持相对导入深度不变) - middleware/auth.global.ts 复刻路由守卫:public 放行、未初始化实例重定向 /setup、登录态与 requiresAuth/requiresAdmin/guestOnly 判定 - 仍用 vue-i18n 9.14.5(与旧前端同版本,避免行为漂移),经 client 插件注入 - 42 个行为测试 + 测试类型门禁(tests/tsconfig.json 继承 .nuxt), npm run verify 走 scripts/verify.mjs 规避 npm && 链在 Windows 上命中全局 nuxt - 覆盖:暗色模式、Ctrl+K、g i/g p/c// 快捷键、SSE 解析与退避重连、未读计数、 i18n 中英切换、CSRF 头、登录回读 /user/me、Tab 契约、TopNav 管理入口、 16 视图与页面壳导入契约 web/: - 补齐 web/src/constants/tabs.ts 与 web/src/utils/error.ts:beta 上已有 8 个文件 import 它们却未提交,fresh clone 后构建会失败 - 重建 web/dist(router 变更后的 embed 产物) 验证:nuxt typecheck / npm run verify / nuxt generate / vue-tsc + vite build 均通过;Go 侧 gofmt / go vet / go test ./... / go build 全绿。 --- web-nuxt/.gitignore | 7 + web-nuxt/README.md | 77 + web-nuxt/app/api/client.ts | 35 + web-nuxt/app/app.vue | 12 + .../app/components/common/ActivityFeed.vue | 93 + .../app/components/common/ActivityItem.vue | 133 + .../app/components/common/CommandPalette.vue | 214 + .../components/common/ContributionGraph.vue | 142 + .../app/components/common/FeatureCard.vue | 43 + .../app/components/common/MarkdownView.vue | 97 + .../app/components/common/RepoSidebarCard.vue | 183 + .../app/components/common/SavedReplies.vue | 349 + web-nuxt/app/components/layout/Sidebar.vue | 136 + web-nuxt/app/components/layout/TopNav.vue | 399 + .../app/components/repo/CloneDropdown.vue | 226 + web-nuxt/app/components/repo/DeployKeys.vue | 327 + .../app/components/repo/LanguageStats.vue | 104 + .../app/components/repo/RepoCodeBrowser.vue | 364 + .../app/components/repo/RepoDangerZone.vue | 203 + web-nuxt/app/composables/useSSE.ts | 85 + web-nuxt/app/composables/useShortcuts.ts | 58 + web-nuxt/app/composables/useTheme.ts | 54 + web-nuxt/app/constants/tabs.ts | 28 + web-nuxt/app/i18n.ts | 48 + web-nuxt/app/locales/en-US.json | 35 + web-nuxt/app/locales/zh-CN.json | 35 + web-nuxt/app/middleware/auth.global.ts | 31 + web-nuxt/app/pages/[owner]/[repo].vue | 8 + web-nuxt/app/pages/admin.vue | 10 + web-nuxt/app/pages/dashboard.vue | 10 + web-nuxt/app/pages/explore.vue | 10 + web-nuxt/app/pages/index.vue | 8 + web-nuxt/app/pages/login.vue | 10 + web-nuxt/app/pages/new/import.vue | 10 + web-nuxt/app/pages/new/org.vue | 10 + web-nuxt/app/pages/new/repo.vue | 10 + web-nuxt/app/pages/oauth/authorize.vue | 8 + web-nuxt/app/pages/orgs/[org]/settings.vue | 10 + web-nuxt/app/pages/register.vue | 10 + web-nuxt/app/pages/search.vue | 10 + web-nuxt/app/pages/settings.vue | 10 + web-nuxt/app/pages/setup.vue | 10 + web-nuxt/app/pages/users/[username].vue | 8 + web-nuxt/app/plugins/i18n.client.ts | 9 + web-nuxt/app/stores/index.ts | 218 + web-nuxt/app/style.css | 97 + web-nuxt/app/styles/variables.scss | 35 + web-nuxt/app/types/meta.d.ts | 22 + web-nuxt/app/utils/error.ts | 19 + web-nuxt/app/views/AdminView.vue | 2132 +++ web-nuxt/app/views/DashboardView.vue | 267 + web-nuxt/app/views/ExploreView.vue | 517 + web-nuxt/app/views/ImportRepoView.vue | 124 + web-nuxt/app/views/LandingView.vue | 145 + web-nuxt/app/views/LoginView.vue | 111 + web-nuxt/app/views/NewOrgView.vue | 114 + web-nuxt/app/views/NewRepoView.vue | 131 + web-nuxt/app/views/OAuthAuthorizeView.vue | 206 + web-nuxt/app/views/OrgSettingsView.vue | 402 + web-nuxt/app/views/RegisterView.vue | 94 + web-nuxt/app/views/RepoView.vue | 1869 +++ web-nuxt/app/views/SearchView.vue | 330 + web-nuxt/app/views/SetupView.vue | 113 + web-nuxt/app/views/UserProfileView.vue | 436 + web-nuxt/app/views/UserSettingsView.vue | 1817 +++ web-nuxt/nuxt.config.ts | 49 + web-nuxt/package-lock.json | 10937 ++++++++++++++++ web-nuxt/package.json | 41 + web-nuxt/scripts/verify.mjs | 32 + web-nuxt/tests/auth-store.test.ts | 90 + web-nuxt/tests/client-and-i18n.test.ts | 74 + web-nuxt/tests/command-palette.test.ts | 61 + web-nuxt/tests/shortcuts.test.ts | 74 + web-nuxt/tests/sse-and-notifications.test.ts | 107 + web-nuxt/tests/theme-and-tabs.test.ts | 61 + web-nuxt/tests/topnav.test.ts | 135 + web-nuxt/tests/tsconfig.json | 16 + web-nuxt/tests/views-contract.test.ts | 60 + web-nuxt/tsconfig.json | 9 + web-nuxt/vitest.config.ts | 18 + web/dist/assets/AdminView-Ce4G-hSE.js | 4 - web/dist/assets/AdminView-DXNzDlDd.css | 1 + web/dist/assets/AdminView-cyHDnWs8.js | 4 + web/dist/assets/AdminView-tKPVzmCv.css | 1 - web/dist/assets/DashboardView-B15fi5w_.css | 1 + web/dist/assets/DashboardView-BqtXR9_X.css | 1 - web/dist/assets/DashboardView-BvNbptB9.js | 1 + web/dist/assets/DashboardView-DC2PX0Kl.js | 1 - ...ew-BKeSYLiw.js => ExploreView-BEmsilKX.js} | 2 +- web/dist/assets/ExploreView-BY5pOXlf.css | 1 + web/dist/assets/ExploreView-DAvWznoZ.css | 1 - web/dist/assets/ImportRepoView-DAyQeQVU.css | 1 - ...BnXBbfpj.js => ImportRepoView-DG1mFZdd.js} | 2 +- web/dist/assets/ImportRepoView-DODNBbJW.css | 1 + web/dist/assets/LandingView-BizLz4iM.js | 1 - web/dist/assets/LandingView-CTiElGEm.css | 1 + web/dist/assets/LandingView-DLUVXj5k.js | 1 + web/dist/assets/LandingView-wbjGMTCs.css | 1 - web/dist/assets/LoginView-C3s4oswh.css | 1 + web/dist/assets/LoginView-CiVXUr6Y.js | 1 - web/dist/assets/LoginView-CjbRYy47.css | 1 - web/dist/assets/LoginView-Dx0aYEwY.js | 1 + web/dist/assets/NewOrgView-DZqBj0NE.css | 1 - ...iew-B9KiuY2U.js => NewOrgView-qkxqWvs_.js} | 2 +- web/dist/assets/NewOrgView-yvCYg7gw.css | 1 + web/dist/assets/NewRepoView-2W2zwQRT.css | 1 + web/dist/assets/NewRepoView-DQmCRV1E.css | 1 - ...ew-Gq3lNELi.js => NewRepoView-o7thShMX.js} | 2 +- .../assets/OAuthAuthorizeView-Cvejpgty.js | 1 - .../assets/OAuthAuthorizeView-DjdzYRqo.js | 1 + .../assets/OAuthAuthorizeView-Dy2HZDQL.css | 1 - .../assets/OAuthAuthorizeView-kS3N9Prm.css | 1 + web/dist/assets/OrgSettingsView-3NimOqIR.js | 1 - web/dist/assets/OrgSettingsView-Bc0txdW-.css | 1 - web/dist/assets/OrgSettingsView-PRF2QAke.css | 1 + web/dist/assets/OrgSettingsView-tmjgBdEI.js | 1 + web/dist/assets/RegisterView-BA4V7Tnu.js | 2 - web/dist/assets/RegisterView-D-4ivPNE.js | 2 + web/dist/assets/RegisterView-DKoQ78oZ.css | 1 - web/dist/assets/RegisterView-D_IwAbb7.css | 1 + web/dist/assets/RepoView-CFSJ63_n.css | 1 + web/dist/assets/RepoView-Cj6C1yHc.css | 1 - ...oView-CyA_SPqM.js => RepoView-D6wrerm8.js} | 22 +- ...iew-CC7EJcRZ.js => SearchView-CJyyWCwl.js} | 2 +- web/dist/assets/SearchView-F6idG_Zt.css | 1 + web/dist/assets/SearchView-KCeA3TOz.css | 1 - web/dist/assets/SetupView-AyTBIDuR.css | 1 - ...View-a0HIDAAq.js => SetupView-CZS8dzit.js} | 2 +- web/dist/assets/SetupView-D-Vt96Nv.css | 1 + web/dist/assets/Sidebar-58-BQfSb.css | 1 + web/dist/assets/Sidebar-BaMFz0Yr.css | 1 - ...idebar-w6XaqOPT.js => Sidebar-_-AQzwoa.js} | 2 +- ...{TopNav-v1yoKCY7.js => TopNav-BU2AJ_IV.js} | 2 +- web/dist/assets/TopNav-Ds3ihoey.css | 1 + web/dist/assets/TopNav-Duw0tKCJ.css | 1 - web/dist/assets/UserProfileView-BA_uK_Wn.css | 1 + web/dist/assets/UserProfileView-DQ9HPyoM.css | 1 - ...QIDSa1g.js => UserProfileView-b7Dn1iNS.js} | 2 +- web/dist/assets/UserSettingsView-B4jOOdj4.js | 2 + web/dist/assets/UserSettingsView-CI06aM3n.css | 1 - web/dist/assets/UserSettingsView-DV5L_8XU.css | 1 + web/dist/assets/UserSettingsView-e03lFYlt.js | 2 - web/dist/assets/error-BsTNDUSp.js | 1 + .../{index-DU-AdffL.js => index-BInlEPnM.js} | 40 +- ...{index-CjePbfOS.css => index-bXpczrx4.css} | 2 +- web/dist/index.html | 26 +- web/src/composables/useShortcuts.ts | 7 +- web/src/constants/tabs.ts | 28 + web/src/i18n.ts | 34 +- web/src/router/index.ts | 16 +- web/src/stores/index.ts | 8 +- web/src/utils/error.ts | 19 + web/src/views/AdminView.vue | 11 +- web/src/views/LoginView.vue | 5 +- web/src/views/OAuthAuthorizeView.vue | 11 +- web/src/views/OrgSettingsView.vue | 17 +- web/src/views/RegisterView.vue | 5 +- web/src/views/RepoView.vue | 9 +- web/src/views/UserSettingsView.vue | 15 +- 159 files changed, 24841 insertions(+), 157 deletions(-) create mode 100644 web-nuxt/.gitignore create mode 100644 web-nuxt/README.md create mode 100644 web-nuxt/app/api/client.ts create mode 100644 web-nuxt/app/app.vue create mode 100644 web-nuxt/app/components/common/ActivityFeed.vue create mode 100644 web-nuxt/app/components/common/ActivityItem.vue create mode 100644 web-nuxt/app/components/common/CommandPalette.vue create mode 100644 web-nuxt/app/components/common/ContributionGraph.vue create mode 100644 web-nuxt/app/components/common/FeatureCard.vue create mode 100644 web-nuxt/app/components/common/MarkdownView.vue create mode 100644 web-nuxt/app/components/common/RepoSidebarCard.vue create mode 100644 web-nuxt/app/components/common/SavedReplies.vue create mode 100644 web-nuxt/app/components/layout/Sidebar.vue create mode 100644 web-nuxt/app/components/layout/TopNav.vue create mode 100644 web-nuxt/app/components/repo/CloneDropdown.vue create mode 100644 web-nuxt/app/components/repo/DeployKeys.vue create mode 100644 web-nuxt/app/components/repo/LanguageStats.vue create mode 100644 web-nuxt/app/components/repo/RepoCodeBrowser.vue create mode 100644 web-nuxt/app/components/repo/RepoDangerZone.vue create mode 100644 web-nuxt/app/composables/useSSE.ts create mode 100644 web-nuxt/app/composables/useShortcuts.ts create mode 100644 web-nuxt/app/composables/useTheme.ts create mode 100644 web-nuxt/app/constants/tabs.ts create mode 100644 web-nuxt/app/i18n.ts create mode 100644 web-nuxt/app/locales/en-US.json create mode 100644 web-nuxt/app/locales/zh-CN.json create mode 100644 web-nuxt/app/middleware/auth.global.ts create mode 100644 web-nuxt/app/pages/[owner]/[repo].vue create mode 100644 web-nuxt/app/pages/admin.vue create mode 100644 web-nuxt/app/pages/dashboard.vue create mode 100644 web-nuxt/app/pages/explore.vue create mode 100644 web-nuxt/app/pages/index.vue create mode 100644 web-nuxt/app/pages/login.vue create mode 100644 web-nuxt/app/pages/new/import.vue create mode 100644 web-nuxt/app/pages/new/org.vue create mode 100644 web-nuxt/app/pages/new/repo.vue create mode 100644 web-nuxt/app/pages/oauth/authorize.vue create mode 100644 web-nuxt/app/pages/orgs/[org]/settings.vue create mode 100644 web-nuxt/app/pages/register.vue create mode 100644 web-nuxt/app/pages/search.vue create mode 100644 web-nuxt/app/pages/settings.vue create mode 100644 web-nuxt/app/pages/setup.vue create mode 100644 web-nuxt/app/pages/users/[username].vue create mode 100644 web-nuxt/app/plugins/i18n.client.ts create mode 100644 web-nuxt/app/stores/index.ts create mode 100644 web-nuxt/app/style.css create mode 100644 web-nuxt/app/styles/variables.scss create mode 100644 web-nuxt/app/types/meta.d.ts create mode 100644 web-nuxt/app/utils/error.ts create mode 100644 web-nuxt/app/views/AdminView.vue create mode 100644 web-nuxt/app/views/DashboardView.vue create mode 100644 web-nuxt/app/views/ExploreView.vue create mode 100644 web-nuxt/app/views/ImportRepoView.vue create mode 100644 web-nuxt/app/views/LandingView.vue create mode 100644 web-nuxt/app/views/LoginView.vue create mode 100644 web-nuxt/app/views/NewOrgView.vue create mode 100644 web-nuxt/app/views/NewRepoView.vue create mode 100644 web-nuxt/app/views/OAuthAuthorizeView.vue create mode 100644 web-nuxt/app/views/OrgSettingsView.vue create mode 100644 web-nuxt/app/views/RegisterView.vue create mode 100644 web-nuxt/app/views/RepoView.vue create mode 100644 web-nuxt/app/views/SearchView.vue create mode 100644 web-nuxt/app/views/SetupView.vue create mode 100644 web-nuxt/app/views/UserProfileView.vue create mode 100644 web-nuxt/app/views/UserSettingsView.vue create mode 100644 web-nuxt/nuxt.config.ts create mode 100644 web-nuxt/package-lock.json create mode 100644 web-nuxt/package.json create mode 100644 web-nuxt/scripts/verify.mjs create mode 100644 web-nuxt/tests/auth-store.test.ts create mode 100644 web-nuxt/tests/client-and-i18n.test.ts create mode 100644 web-nuxt/tests/command-palette.test.ts create mode 100644 web-nuxt/tests/shortcuts.test.ts create mode 100644 web-nuxt/tests/sse-and-notifications.test.ts create mode 100644 web-nuxt/tests/theme-and-tabs.test.ts create mode 100644 web-nuxt/tests/topnav.test.ts create mode 100644 web-nuxt/tests/tsconfig.json create mode 100644 web-nuxt/tests/views-contract.test.ts create mode 100644 web-nuxt/tsconfig.json create mode 100644 web-nuxt/vitest.config.ts delete mode 100644 web/dist/assets/AdminView-Ce4G-hSE.js create mode 100644 web/dist/assets/AdminView-DXNzDlDd.css create mode 100644 web/dist/assets/AdminView-cyHDnWs8.js delete mode 100644 web/dist/assets/AdminView-tKPVzmCv.css create mode 100644 web/dist/assets/DashboardView-B15fi5w_.css delete mode 100644 web/dist/assets/DashboardView-BqtXR9_X.css create mode 100644 web/dist/assets/DashboardView-BvNbptB9.js delete mode 100644 web/dist/assets/DashboardView-DC2PX0Kl.js rename web/dist/assets/{ExploreView-BKeSYLiw.js => ExploreView-BEmsilKX.js} (98%) create mode 100644 web/dist/assets/ExploreView-BY5pOXlf.css delete mode 100644 web/dist/assets/ExploreView-DAvWznoZ.css delete mode 100644 web/dist/assets/ImportRepoView-DAyQeQVU.css rename web/dist/assets/{ImportRepoView-BnXBbfpj.js => ImportRepoView-DG1mFZdd.js} (92%) create mode 100644 web/dist/assets/ImportRepoView-DODNBbJW.css delete mode 100644 web/dist/assets/LandingView-BizLz4iM.js create mode 100644 web/dist/assets/LandingView-CTiElGEm.css create mode 100644 web/dist/assets/LandingView-DLUVXj5k.js delete mode 100644 web/dist/assets/LandingView-wbjGMTCs.css create mode 100644 web/dist/assets/LoginView-C3s4oswh.css delete mode 100644 web/dist/assets/LoginView-CiVXUr6Y.js delete mode 100644 web/dist/assets/LoginView-CjbRYy47.css create mode 100644 web/dist/assets/LoginView-Dx0aYEwY.js delete mode 100644 web/dist/assets/NewOrgView-DZqBj0NE.css rename web/dist/assets/{NewOrgView-B9KiuY2U.js => NewOrgView-qkxqWvs_.js} (89%) create mode 100644 web/dist/assets/NewOrgView-yvCYg7gw.css create mode 100644 web/dist/assets/NewRepoView-2W2zwQRT.css delete mode 100644 web/dist/assets/NewRepoView-DQmCRV1E.css rename web/dist/assets/{NewRepoView-Gq3lNELi.js => NewRepoView-o7thShMX.js} (90%) delete mode 100644 web/dist/assets/OAuthAuthorizeView-Cvejpgty.js create mode 100644 web/dist/assets/OAuthAuthorizeView-DjdzYRqo.js delete mode 100644 web/dist/assets/OAuthAuthorizeView-Dy2HZDQL.css create mode 100644 web/dist/assets/OAuthAuthorizeView-kS3N9Prm.css delete mode 100644 web/dist/assets/OrgSettingsView-3NimOqIR.js delete mode 100644 web/dist/assets/OrgSettingsView-Bc0txdW-.css create mode 100644 web/dist/assets/OrgSettingsView-PRF2QAke.css create mode 100644 web/dist/assets/OrgSettingsView-tmjgBdEI.js delete mode 100644 web/dist/assets/RegisterView-BA4V7Tnu.js create mode 100644 web/dist/assets/RegisterView-D-4ivPNE.js delete mode 100644 web/dist/assets/RegisterView-DKoQ78oZ.css create mode 100644 web/dist/assets/RegisterView-D_IwAbb7.css create mode 100644 web/dist/assets/RepoView-CFSJ63_n.css delete mode 100644 web/dist/assets/RepoView-Cj6C1yHc.css rename web/dist/assets/{RepoView-CyA_SPqM.js => RepoView-D6wrerm8.js} (71%) rename web/dist/assets/{SearchView-CC7EJcRZ.js => SearchView-CJyyWCwl.js} (90%) create mode 100644 web/dist/assets/SearchView-F6idG_Zt.css delete mode 100644 web/dist/assets/SearchView-KCeA3TOz.css delete mode 100644 web/dist/assets/SetupView-AyTBIDuR.css rename web/dist/assets/{SetupView-a0HIDAAq.js => SetupView-CZS8dzit.js} (92%) create mode 100644 web/dist/assets/SetupView-D-Vt96Nv.css create mode 100644 web/dist/assets/Sidebar-58-BQfSb.css delete mode 100644 web/dist/assets/Sidebar-BaMFz0Yr.css rename web/dist/assets/{Sidebar-w6XaqOPT.js => Sidebar-_-AQzwoa.js} (70%) rename web/dist/assets/{TopNav-v1yoKCY7.js => TopNav-BU2AJ_IV.js} (82%) create mode 100644 web/dist/assets/TopNav-Ds3ihoey.css delete mode 100644 web/dist/assets/TopNav-Duw0tKCJ.css create mode 100644 web/dist/assets/UserProfileView-BA_uK_Wn.css delete mode 100644 web/dist/assets/UserProfileView-DQ9HPyoM.css rename web/dist/assets/{UserProfileView-7QIDSa1g.js => UserProfileView-b7Dn1iNS.js} (96%) create mode 100644 web/dist/assets/UserSettingsView-B4jOOdj4.js delete mode 100644 web/dist/assets/UserSettingsView-CI06aM3n.css create mode 100644 web/dist/assets/UserSettingsView-DV5L_8XU.css delete mode 100644 web/dist/assets/UserSettingsView-e03lFYlt.js create mode 100644 web/dist/assets/error-BsTNDUSp.js rename web/dist/assets/{index-DU-AdffL.js => index-BInlEPnM.js} (65%) rename web/dist/assets/{index-CjePbfOS.css => index-bXpczrx4.css} (79%) create mode 100644 web/src/constants/tabs.ts create mode 100644 web/src/utils/error.ts 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 @@ + + +