diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md new file mode 100644 index 0000000..116bfa8 --- /dev/null +++ b/server/CONVENTIONS.md @@ -0,0 +1,266 @@ +# Pangolin 后端编码规范 (CONVENTIONS) + +> 本文件是给 Claude Code 和所有后端开发者的**强制性**编码规约。 +> 与 `design/server/ARCHITECTURE.md` 配合使用:架构文档说「做什么」,本文说「怎么做」。 + +--- + +## 0. 铁律(违反即阻断 PR) + +1. **API 错误文案双语**:所有面向客户端的错误必须使用 `apierr.Error`(包含 `message_zh` + `message_en`)。 +2. **脱敏**:任何错误文案、日志、注释中禁止出现「VPN」「翻墙」「科学上网」。 +3. **明文激活码禁止入库**:只能存储 `idgen.HashCode(canonical)`;明文只能存在于生成响应和 CSV 导出的内存流中。 +4. **无日志口径**:不记录用户的目的地址、DNS 查询、流量内容;只记录 `usage_daily` 的字节数和分钟数。 + +--- + +## 1. 包结构 + +``` +server/ +├── cmd/ 应用入口(main 包),每个二进制一个子目录 +│ ├── api/ 控制面 HTTP 服务 +│ └── agent/ 节点 agent(gRPC 服务) +├── internal/ 私有实现,不对外暴露 +│ ├── apierr/ API 错误类型与辅助函数(零依赖) +│ ├── idgen/ ID 生成(UUID v7 + Crockford Base32) +│ ├── auth/ 认证(邮箱验证码 / argon2id / JWT RS256) +│ ├── codes/ 激活码生命周期(生成 / webhook / 兑换 / 审计) +│ ├── devices/ 设备管理 +│ ├── nodes/ 节点目录 + connect/disconnect +│ ├── usage/ 用量聚合 + 广告解锁 +│ ├── admin/ 管理端(内部,独立端口) +│ ├── config/ 配置加载 +│ ├── db/ 数据库连接 + migration +│ ├── redisutil/ Redis 连接 +│ ├── store/ 跨模块的共享 DB 查询 +│ └── mtls/ 节点 mTLS 证书管理 +├── api/ OpenAPI 契约(openapi.yaml) +├── migrations/ SQL migration 文件(golang-migrate 格式) +└── CONVENTIONS.md 本文件 +``` + +**包命名**:小写单词,无缩写(`redisutil` 可接受);禁止包名与 Go 标准库重名。 + +**导入顺序**(`goimports` / `golangci-lint` 强制): +1. 标准库 +2. 第三方包(`github.com/xxx/yyy`,非本项目) +3. 本项目内部包(`github.com/wangjia/pangolin/server/internal/…`) + +各组之间留一个空行。 + +--- + +## 2. 错误处理(`internal/apierr`) + +### 2.1 规则 + +- **所有 HTTP handler 的错误响应**必须经过 `apierr.WriteJSON`。 +- **绝不**直接 `http.Error(w, "...", status)` 或 `json.Encode(map[string]string{...})`。 +- 若使用 `apierr.Middleware`,handler 可以 `panic(apierr.ErrXxx)` 代替 `WriteJSON + return`;但不可混用两种风格。 + +### 2.2 使用模式 + +```go +// ❌ 错误示例 +http.Error(w, "unauthorized", http.StatusUnauthorized) + +// ✅ 正确:使用预定义错误 +apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) +return + +// ✅ 正确:使用 New 创建临时错误 +apierr.WriteJSON(w, http.StatusBadRequest, apierr.New( + "DEVICE_LIMIT_EXCEEDED", + "设备数量已达套餐上限", + "Device limit reached for your plan", +)) +return +``` + +### 2.3 HTTP 状态码映射 + +| 场景 | 错误 Code | HTTP 状态 | +|------|-----------|-----------| +| 请求体格式错误 / 缺少字段 | `BAD_REQUEST` | 400 | +| JWT 缺失或过期 | `UNAUTHORIZED` | 401 | +| 操作不允许(权限不足) | `FORBIDDEN` | 403 | +| 资源不存在 | `NOT_FOUND` | 404 | +| 状态冲突(幂等操作重复但语义不同) | `CONFLICT` | 409 | +| 限流 / 账户锁定 | `RATE_LIMITED` / `ACCOUNT_LOCKED` | 429 | +| 未预期的内部错误 | `INTERNAL_ERROR` | 500 | + +`apierr.StatusFor(e)` 实现了上述映射;显式指定 status 时应与映射一致。 + +### 2.4 错误日志 + +- 业务错误(4xx):**不记录日志**(用户侧错误,无需报警)。 +- 内部错误(5xx):使用 `slog.Error("...", "error", err)` 记录,不在响应体中暴露原始 error。 + +--- + +## 3. ID 生成(`internal/idgen`) + +### 3.1 实体 ID(UUID v7) + +所有数据库实体(users、devices、subscriptions、nodes……)使用 UUID v7: + +```go +import "github.com/wangjia/pangolin/server/internal/idgen" + +// 新建记录时 +id := idgen.NewString() // "018efa4b-xxxx-7xxx-xxxx-xxxxxxxxxxxx" +``` + +UUID v7 优点:时间有序(B-tree 索引友好)+ 全局唯一 + 不暴露自增 ID。 + +**禁止**使用数据库自增整型 `AUTO_INCREMENT` 作为主键(整型 ID 易被枚举,且不适合分布式)。 + +### 3.2 激活码(Crockford Base32) + +激活码通过 `idgen.GenerateCode()` 生成,格式为 16 字符 Crockford Base32(含 mod-37 校验位)。 +**只存储 hash,不存明文**: + +```go +code, err := idgen.GenerateCode() // "0123456789ABCDE3" (明文,一次性) +hash := idgen.HashCode(code) // sha256 hex,存入 DB +canonical, err := idgen.CanonicalizeCode(userInput) // 输入规范化 + 校验 +``` + +### 3.3 Redis Key 命名 + +``` +:: → e.g. redeem:fail:018efa4b-... (失败计数) +: → e.g. node:version (全局版本号) +webhook:nonce: → webhook 去重 +``` + +--- + +## 4. 数据库操作 + +### 4.1 原则 + +- 每个模块拥有自己的 `store.go`(只操作该模块的表)。 +- 跨模块读取(只读)允许在自己的 `store.go` 中实现 JOIN;跨模块写入必须通过各模块的 Service。 +- 事务(`sql.Tx`)在 Service 层开启和提交,Store 方法接受 `*sql.Tx` 参数。 + +### 4.2 命名 + +| 类型 | 命名 | +|------|------| +| 查询单行 | `FindBy` | +| 查询多行 | `ListBy` | +| 插入 | `Insert` | +| 更新 | `Update` | +| 删除 | `Delete` | +| 带锁查询 | `FindByForUpdate` | + +### 4.3 Migration 文件 + +文件名格式:`NNN_.up.sql` / `.down.sql`(golang-migrate 规范)。 +每个 migration 必须可回滚(down.sql 不得为空)。 + +--- + +## 5. HTTP Handler 结构 + +每个模块的 handler 遵循以下模板: + +```go +// Handler 结构体持有 Service 引用(不直接持有 Store)。 +type RedeemHandler struct { + svc *Service +} + +func NewRedeemHandler(svc *Service) *RedeemHandler { ... } + +// ServeHTTP 方法: +// 1. 路由方法校验 +// 2. 从 Context 取认证信息 +// 3. 解析请求体 +// 4. 调用 Service +// 5. 写响应(成功 2xx JSON / 失败 apierr.WriteJSON) +func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... } +``` + +**禁止** Handler 直接操作数据库(不注入 Store / DB)。 + +--- + +## 6. 认证上下文 + +JWT 中间件(`internal/auth`)将 claims 注入 `context.Context`。 +各 handler 通过统一的 context key 取值: + +```go +// 在 handler 中取认证用户 ID: +userID, ok := r.Context().Value(codes.CtxKeyUserID).(int64) +if !ok || userID == 0 { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return +} +``` + +--- + +## 7. 测试规范 + +### 7.1 分层 + +| 类型 | 文件命名 | 依赖 | +|------|---------|------| +| 单元测试 | `*_test.go`(`package xxx_test`)| 纯内存,无外部服务 | +| 集成测试 | `*_integration_test.go` | testcontainers(MySQL + Redis)| + +### 7.2 要求 + +- **每个导出函数/方法至少有一个单元测试**(happy path + 至少一个 error path)。 +- 覆盖率目标:核心包(`apierr`、`idgen`、`codes`)≥ 90%。 +- 集成测试使用 testcontainers,**不依赖外部服务**,可在 CI 无配置跑通。 +- 禁止在测试中硬编码随机种子;使用 `crypto/rand` 的函数天然随机,不需要固定 seed。 + +### 7.3 测试辅助函数命名 + +```go +// 必须:t.Helper() +// 命名:must(always fatal on error) +func mustGenerateCode(t *testing.T) string { + t.Helper() + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + return code +} +``` + +--- + +## 8. 日志规范 + +使用 `log/slog`(Go 1.21+): + +```go +slog.Info("code redeemed", "user_id", userID, "duration_days", result.DurationDays) +slog.Error("internal error", "error", err, "endpoint", r.URL.Path) +``` + +**绝对禁止**在日志中输出: +- 明文激活码 +- 用户密码 / 密钥 +- 目的地址 / DNS 查询内容 +- 完整 JWT token + +--- + +## 9. CI 检查 + +`Makefile` 的 `make lint` 和 `make test` 必须全部通过才能合并。 + +``` +make lint → golangci-lint (errcheck, staticcheck, gosec, goimports) +make test → go test ./...(包含单元测试;集成测试需 -tags integration) +``` + +OpenAPI 同步:`make openapi-check` 验证 `api/openapi.yaml` 与 handler 签名一致(oapi-codegen)。 diff --git a/server/go.mod b/server/go.mod index 3a95d70..bf92077 100644 --- a/server/go.mod +++ b/server/go.mod @@ -109,7 +109,7 @@ require ( github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect diff --git a/server/internal/apierr/apierr.go b/server/internal/apierr/apierr.go index 81b6d53..2d597d4 100644 --- a/server/internal/apierr/apierr.go +++ b/server/internal/apierr/apierr.go @@ -1,5 +1,10 @@ -// Package apierr defines the bilingual error response type used across all API modules. -// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording. +// Package apierr defines the canonical error response shape used across all +// v1 API handlers: {code, message_zh, message_en}. It provides constructor +// helpers for common HTTP error categories (400/401/403/404/409/429/500) +// and a middleware that serialises *Error values to JSON automatically. +// +// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / +// "科学上网" wording is permitted in any user-facing message. package apierr import ( @@ -8,15 +13,66 @@ import ( ) // Error is the canonical API error body: {code, message_zh, message_en}. +// All v1 handlers must return errors in this shape — never raw strings. type Error struct { Code string `json:"code"` MessageZH string `json:"message_zh"` MessageEn string `json:"message_en"` } +// Error implements the error interface. func (e *Error) Error() string { return e.Code + ": " + e.MessageEn } -// Standard code-module errors. +// New creates a new *Error with an application error code and bilingual messages. +// Use this when none of the predefined errors fit the situation. +func New(code, messageZH, messageEn string) *Error { + return &Error{Code: code, MessageZH: messageZH, MessageEn: messageEn} +} + +// ───────────────────────────────────────────────────────────────────────────── +// Predefined errors — common HTTP error categories +// ───────────────────────────────────────────────────────────────────────────── + +// General HTTP-category errors (400 / 401 / 403 / 404 / 409 / 429 / 500). +var ( + ErrBadRequest = &Error{ + Code: "BAD_REQUEST", + MessageZH: "请求参数有误", + MessageEn: "Invalid request parameters", + } + ErrUnauthorized = &Error{ + Code: "UNAUTHORIZED", + MessageZH: "请先登录", + MessageEn: "Authentication required", + } + ErrForbidden = &Error{ + Code: "FORBIDDEN", + MessageZH: "权限不足", + MessageEn: "Permission denied", + } + ErrNotFound = &Error{ + Code: "NOT_FOUND", + MessageZH: "资源不存在", + MessageEn: "Resource not found", + } + ErrConflict = &Error{ + Code: "CONFLICT", + MessageZH: "资源状态冲突", + MessageEn: "Resource state conflict", + } + ErrRateLimited = &Error{ + Code: "RATE_LIMITED", + MessageZH: "操作过于频繁,请稍后再试", + MessageEn: "Too many attempts, please try again later", + } + ErrInternal = &Error{ + Code: "INTERNAL_ERROR", + MessageZH: "服务器内部错误,请稍后重试", + MessageEn: "Internal server error, please try again later", + } +) + +// Activation-code errors. var ( ErrInvalidCode = &Error{ Code: "INVALID_CODE", @@ -38,28 +94,15 @@ var ( MessageZH: "该激活码已失效", MessageEn: "This code is no longer valid", } - ErrRateLimited = &Error{ - Code: "RATE_LIMITED", - MessageZH: "操作过于频繁,请稍后再试", - MessageEn: "Too many attempts, please try again later", - } ErrLocked = &Error{ Code: "ACCOUNT_LOCKED", MessageZH: "账户已临时锁定,请1小时后重试", MessageEn: "Account temporarily locked, please retry in 1 hour", } - ErrInternal = &Error{ - Code: "INTERNAL_ERROR", - MessageZH: "服务器内部错误,请稍后重试", - MessageEn: "Internal server error, please try again later", - } - ErrBadRequest = &Error{ - Code: "BAD_REQUEST", - MessageZH: "请求参数有误", - MessageEn: "Invalid request parameters", - } +) - // Webhook-specific errors. +// Webhook-specific errors. +var ( ErrWebhookSignature = &Error{ Code: "WEBHOOK_INVALID_SIGNATURE", MessageZH: "签名校验失败", @@ -77,9 +120,71 @@ var ( } ) -// WriteJSON writes the given status code and error body as JSON. +// ───────────────────────────────────────────────────────────────────────────── +// HTTP helpers +// ───────────────────────────────────────────────────────────────────────────── + +// StatusFor returns a suitable HTTP status code for the given *Error, inferred +// from the error Code string. It covers the standard mapping used across all +// v1 handlers; callers may override with explicit WriteJSON calls when needed. +func StatusFor(e *Error) int { + switch e.Code { + case "UNAUTHORIZED": + return http.StatusUnauthorized + case "FORBIDDEN": + return http.StatusForbidden + case "NOT_FOUND": + return http.StatusNotFound + case "CONFLICT": + return http.StatusConflict + case "RATE_LIMITED", "ACCOUNT_LOCKED": + return http.StatusTooManyRequests + case "INTERNAL_ERROR": + return http.StatusInternalServerError + default: + return http.StatusBadRequest + } +} + +// WriteJSON writes the given HTTP status code and error body as JSON. func WriteJSON(w http.ResponseWriter, status int, e *Error) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(e) } + +// ───────────────────────────────────────────────────────────────────────────── +// Chi-compatible middleware +// ───────────────────────────────────────────────────────────────────────────── + +// Middleware is a chi-compatible middleware that recovers from panics of type +// *Error and writes the appropriate JSON response via StatusFor + WriteJSON. +// Any panic with a non-*Error value is re-raised so other recovery middleware +// (e.g. chi's built-in Recoverer) can handle it. +// +// Usage in handlers — instead of: +// +// apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) +// return +// +// A handler may simply: +// +// panic(apierr.ErrBadRequest) +// +// This keeps handler code linear and avoids partial-write bugs when the caller +// forgets to return after WriteJSON. +func Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rv := recover(); rv != nil { + if e, ok := rv.(*Error); ok { + WriteJSON(w, StatusFor(e), e) + return + } + // Unknown panic type — re-raise for upstream recovery middleware. + panic(rv) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/server/internal/apierr/apierr_test.go b/server/internal/apierr/apierr_test.go new file mode 100644 index 0000000..1efa115 --- /dev/null +++ b/server/internal/apierr/apierr_test.go @@ -0,0 +1,210 @@ +package apierr_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wangjia/pangolin/server/internal/apierr" +) + +// TestErrorInterface verifies that *Error implements the error interface. +func TestErrorInterface(t *testing.T) { + var err error = apierr.ErrBadRequest + if err == nil { + t.Fatal("ErrBadRequest should not be nil") + } + if err.Error() == "" { + t.Error("Error() returned empty string") + } +} + +// TestNew verifies that New constructs an *Error with the given fields. +func TestNew(t *testing.T) { + e := apierr.New("TEST_CODE", "测试消息", "test message") + if e.Code != "TEST_CODE" { + t.Errorf("Code = %q, want %q", e.Code, "TEST_CODE") + } + if e.MessageZH != "测试消息" { + t.Errorf("MessageZH = %q, want %q", e.MessageZH, "测试消息") + } + if e.MessageEn != "test message" { + t.Errorf("MessageEn = %q, want %q", e.MessageEn, "test message") + } +} + +// TestStatusFor verifies HTTP status code mapping. +func TestStatusFor(t *testing.T) { + cases := []struct { + code string + wantStatus int + }{ + {"UNAUTHORIZED", http.StatusUnauthorized}, + {"FORBIDDEN", http.StatusForbidden}, + {"NOT_FOUND", http.StatusNotFound}, + {"CONFLICT", http.StatusConflict}, + {"RATE_LIMITED", http.StatusTooManyRequests}, + {"ACCOUNT_LOCKED", http.StatusTooManyRequests}, + {"INTERNAL_ERROR", http.StatusInternalServerError}, + {"BAD_REQUEST", http.StatusBadRequest}, + {"INVALID_CODE", http.StatusBadRequest}, + {"CODE_NOT_FOUND", http.StatusBadRequest}, + {"UNKNOWN_CODE", http.StatusBadRequest}, + } + for _, tc := range cases { + e := &apierr.Error{Code: tc.code} + got := apierr.StatusFor(e) + if got != tc.wantStatus { + t.Errorf("StatusFor({Code:%q}) = %d, want %d", tc.code, got, tc.wantStatus) + } + } +} + +// TestWriteJSON verifies that WriteJSON sets the correct Content-Type, +// status code, and JSON body. +func TestWriteJSON(t *testing.T) { + e := apierr.New("TEST", "中文", "english") + + w := httptest.NewRecorder() + apierr.WriteJSON(w, http.StatusBadRequest, e) + + resp := w.Result() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest) + } + ct := resp.Header.Get("Content-Type") + if ct != "application/json; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8") + } + + var got apierr.Error + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Code != e.Code { + t.Errorf("body.code = %q, want %q", got.Code, e.Code) + } + if got.MessageZH != e.MessageZH { + t.Errorf("body.message_zh = %q, want %q", got.MessageZH, e.MessageZH) + } + if got.MessageEn != e.MessageEn { + t.Errorf("body.message_en = %q, want %q", got.MessageEn, e.MessageEn) + } +} + +// TestMiddlewareCatchesAPIError verifies that the middleware intercepts +// a panic(*Error) and writes the correct JSON response. +func TestMiddlewareCatchesAPIError(t *testing.T) { + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic(apierr.ErrUnauthorized) + }) + + handler := apierr.Middleware(panicHandler) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + resp := w.Result() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) + } + + var got apierr.Error + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.Code != apierr.ErrUnauthorized.Code { + t.Errorf("body.code = %q, want %q", got.Code, apierr.ErrUnauthorized.Code) + } +} + +// TestMiddlewareReRaisesNonAPIError verifies that the middleware re-raises +// panics that are not of type *Error. +func TestMiddlewareReRaisesNonAPIError(t *testing.T) { + panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("unexpected string panic") + }) + + handler := apierr.Middleware(panicHandler) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + + defer func() { + if rv := recover(); rv == nil { + t.Error("expected panic to be re-raised, but it was not") + } + }() + handler.ServeHTTP(w, req) +} + +// TestMiddlewarePassesthrough verifies that the middleware is a no-op when +// the handler does not panic. +func TestMiddlewarePassesthrough(t *testing.T) { + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + + handler := apierr.Middleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status = %d, want %d", w.Code, http.StatusOK) + } +} + +// TestPredefinedErrors checks that all predefined errors have non-empty fields +// and comply with the desensitisation rule (no forbidden words in messages). +func TestPredefinedErrors(t *testing.T) { + forbidden := []string{"VPN", "翻墙", "科学上网"} + errors := []*apierr.Error{ + apierr.ErrBadRequest, + apierr.ErrUnauthorized, + apierr.ErrForbidden, + apierr.ErrNotFound, + apierr.ErrConflict, + apierr.ErrRateLimited, + apierr.ErrInternal, + apierr.ErrInvalidCode, + apierr.ErrCodeNotFound, + apierr.ErrCodeRedeemed, + apierr.ErrCodeVoid, + apierr.ErrLocked, + apierr.ErrWebhookSignature, + apierr.ErrWebhookTimestamp, + apierr.ErrWebhookReplay, + } + + for _, e := range errors { + if e.Code == "" { + t.Errorf("error %+v has empty Code", e) + } + if e.MessageZH == "" { + t.Errorf("error %q has empty MessageZH", e.Code) + } + if e.MessageEn == "" { + t.Errorf("error %q has empty MessageEn", e.Code) + } + for _, f := range forbidden { + if contains(e.MessageZH, f) || contains(e.MessageEn, f) { + t.Errorf("error %q contains forbidden word %q", e.Code, f) + } + } + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub)) +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/server/internal/apierr/doc.go b/server/internal/apierr/doc.go index e32a16d..90dd549 100644 --- a/server/internal/apierr/doc.go +++ b/server/internal/apierr/doc.go @@ -1,5 +1 @@ -// Package apierr defines the canonical error response shape used across all -// v1 API handlers: {code, message_zh, message_en}. It provides constructor -// helpers for common HTTP error categories (400/401/403/404/409/429/500) -// and a middleware that serialises *APIError values to JSON automatically. package apierr diff --git a/server/internal/codes/generator.go b/server/internal/codes/generator.go index 31360da..5168fbc 100644 --- a/server/internal/codes/generator.go +++ b/server/internal/codes/generator.go @@ -8,169 +8,32 @@ package codes import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" "errors" - "fmt" - "strings" + + "github.com/wangjia/pangolin/server/internal/idgen" ) -// Crockford Base32 encoding alphabet (32 symbols, excludes I L O U). -const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" - -// crockfordCheck is the extended 37-symbol check-character alphabet. -// Symbols 0–31 are identical to crockfordAlphabet; 32–36 are *, ~, $, =, U. -const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U" - -// crockfordDecode maps every printable ASCII character to its Crockford -// numeric value (0–31), or to -1 if the character is not valid. -var crockfordDecode [128]int8 - -func init() { - for i := range crockfordDecode { - crockfordDecode[i] = -1 - } - for i, ch := range crockfordAlphabet { - crockfordDecode[ch] = int8(i) - // Lower-case equivalents. - if ch >= 'A' && ch <= 'Z' { - crockfordDecode[ch-'A'+'a'] = int8(i) - } - } - // Normalisation rules per Crockford spec: - // I, i, l, L → 1 - // O, o → 0 - crockfordDecode['I'] = crockfordDecode['1'] - crockfordDecode['i'] = crockfordDecode['1'] - crockfordDecode['l'] = crockfordDecode['1'] - crockfordDecode['L'] = crockfordDecode['1'] - crockfordDecode['O'] = crockfordDecode['0'] - crockfordDecode['o'] = crockfordDecode['0'] +// GenerateCode generates a single random activation code in canonical Crockford +// Base32 form (15 random data chars + 1 check char = 16 chars total). +// It delegates to idgen.GenerateCode which uses crypto/rand. +func GenerateCode() (string, error) { + return idgen.GenerateCode() } // Canonicalize converts an activation-code string into canonical form: // uppercase, with I/L→1 and O→0 substitutions applied. +// Hyphens and spaces are stripped before validation. // Returns an error if the string contains characters outside the normalised -// Crockford alphabet or if the length is not exactly 16. +// Crockford alphabet, if the length is not exactly 16, or if the check character +// is incorrect. func Canonicalize(code string) (string, error) { - code = strings.TrimSpace(code) - // Strip any hyphens/spaces inserted for readability (e.g., XXXX-XXXX-XXXX-XXXX). - code = strings.ReplaceAll(code, "-", "") - code = strings.ReplaceAll(code, " ", "") - - if len(code) != 16 { - return "", fmt.Errorf("codes: code must be exactly 16 characters, got %d", len(code)) - } - - var buf [16]byte - for i := 0; i < 16; i++ { - ch := code[i] - if ch >= 128 { - return "", fmt.Errorf("codes: non-ASCII character at position %d", i) - } - v := crockfordDecode[ch] - if v < 0 { - // For the check symbol position (last char) we allow the full 37-symbol set. - // Check separately below after we have the string. - if i < 15 { - return "", fmt.Errorf("codes: invalid character %q at position %d", ch, i) - } - // Position 15 is the check character; validate separately. - buf[i] = byte(strings.ToUpper(string(ch))[0]) - continue - } - buf[i] = crockfordAlphabet[v] - } - - canonical := string(buf[:]) - - // Validate the check character. - if err := validateCheckChar(canonical); err != nil { - return "", err - } - return canonical, nil + return idgen.CanonicalizeCode(code) } // Hash returns the hex-encoded SHA-256 of the canonical plaintext code. // This is the value stored in the database; the plaintext is never stored. func Hash(canonical string) string { - sum := sha256.Sum256([]byte(canonical)) - return hex.EncodeToString(sum[:]) -} - -// computeCheckValue uses Horner's method to compute the Crockford check value -// (mod 37) of the 15 data characters in s[0:15]. -// s must already be in canonical (uppercase) form. -func computeCheckValue(s string) int { - result := 0 - for i := 0; i < 15; i++ { - ch := s[i] - if ch >= 128 { - return -1 - } - v := int(crockfordDecode[ch]) - if v < 0 { - return -1 - } - result = (result*32 + v) % 37 - } - return result -} - -// validateCheckChar verifies that the 16th character of canonical is the -// correct Crockford mod-37 check symbol. -func validateCheckChar(canonical string) error { - if len(canonical) != 16 { - return errors.New("codes: invalid length for check validation") - } - expected := computeCheckValue(canonical) - if expected < 0 { - return errors.New("codes: invalid data characters") - } - want := rune(crockfordCheck[expected]) - got := rune(canonical[15]) - // The check character might be in the extended set (*~$=U) so compare directly. - if got != want { - return fmt.Errorf("codes: check character mismatch: want %c, got %c", want, got) - } - return nil -} - -// GenerateCode generates a single random activation code in canonical Crockford -// Base32 form (15 random data chars + 1 check char = 16 chars total). -// Uses crypto/rand for cryptographically-secure randomness. -func GenerateCode() (string, error) { - // We need 15 random values each in [0, 32). - // To avoid modular bias, use rejection sampling with bytes from crypto/rand. - // Each byte gives a value in [0, 256); we accept values in [0, 224) to ensure - // uniform distribution over [0, 32) (224 = 7*32). - const dataLen = 15 - var buf [dataLen]byte - i := 0 - for i < dataLen { - var tmp [dataLen * 2]byte // over-read to reduce syscall count - if _, err := rand.Read(tmp[:]); err != nil { - return "", fmt.Errorf("codes: crypto/rand: %w", err) - } - for _, b := range tmp { - if b < 224 { // 224 = 7*32; accept to avoid bias - buf[i] = crockfordAlphabet[b%32] - i++ - if i == dataLen { - break - } - } - } - } - - data := string(buf[:]) - checkVal := computeCheckValue(data + "0") // dummy 16th char, only first 15 used - if checkVal < 0 { - // Should never happen since we only use valid chars. - return "", errors.New("codes: internal check computation error") - } - return data + string(crockfordCheck[checkVal]), nil + return idgen.HashCode(canonical) } // ErrDuplicate is returned by the batch generator when a generated code diff --git a/server/internal/idgen/doc.go b/server/internal/idgen/doc.go index 9004797..bac46bf 100644 --- a/server/internal/idgen/doc.go +++ b/server/internal/idgen/doc.go @@ -1,5 +1 @@ -// Package idgen generates application-level identifiers. -// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs). -// For activation codes it produces 16-character Crockford Base32 strings -// with a check digit, suitable for display and human entry. package idgen diff --git a/server/internal/idgen/idgen.go b/server/internal/idgen/idgen.go new file mode 100644 index 0000000..745466d --- /dev/null +++ b/server/internal/idgen/idgen.go @@ -0,0 +1,206 @@ +// Package idgen generates application-level identifiers. +// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs). +// For activation codes it produces 16-character Crockford Base32 strings +// with a check digit, suitable for display and human entry. +package idgen + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" +) + +// ───────────────────────────────────────────────────────────────────────────── +// UUID v7 — time-ordered identifiers for database entities +// ───────────────────────────────────────────────────────────────────────────── + +// New returns a new UUID v7 (time-ordered, random node bits). +// UUID v7 is the recommended identifier format for database entities; its +// time-ordered structure improves B-tree index locality compared with UUID v4. +// Panics only if the system's crypto/rand source is unavailable (a fatal +// misconfiguration; panic is appropriate). +func New() uuid.UUID { + id, err := uuid.NewV7() + if err != nil { + panic("idgen: crypto/rand unavailable: " + err.Error()) + } + return id +} + +// NewString returns a new UUID v7 as a canonical lower-case string +// (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). +func NewString() string { + return New().String() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Crockford Base32 — human-friendly activation codes +// ───────────────────────────────────────────────────────────────────────────── + +// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U +// to prevent visual confusion with 1, 1, 0, and V respectively). +const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// crockfordCheck is the extended 37-symbol check-character alphabet used for +// the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet; +// symbols 32–36 are *, ~, $, =, U. +const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U" + +// crockfordDecode maps every printable ASCII character to its Crockford +// numeric value (0–31), or to -1 if the character is not valid. +// Normalisation rules per the Crockford spec are applied at init time: +// +// I, i, l, L → 1 +// O, o → 0 +var crockfordDecode [128]int8 + +func init() { + for i := range crockfordDecode { + crockfordDecode[i] = -1 + } + for i, ch := range crockfordAlphabet { + crockfordDecode[ch] = int8(i) + // Accept lower-case equivalents. + if ch >= 'A' && ch <= 'Z' { + crockfordDecode[ch-'A'+'a'] = int8(i) + } + } + // Normalisation: I/i/l/L → 1; O/o → 0. + crockfordDecode['I'] = crockfordDecode['1'] + crockfordDecode['i'] = crockfordDecode['1'] + crockfordDecode['l'] = crockfordDecode['1'] + crockfordDecode['L'] = crockfordDecode['1'] + crockfordDecode['O'] = crockfordDecode['0'] + crockfordDecode['o'] = crockfordDecode['0'] +} + +// computeCheckValue computes the Crockford mod-37 check value (Horner's method) +// of the first 15 characters of s (which must already be in canonical form). +// Returns -1 if any character is invalid. +func computeCheckValue(s string) int { + result := 0 + for i := 0; i < 15; i++ { + ch := s[i] + if ch >= 128 { + return -1 + } + v := int(crockfordDecode[ch]) + if v < 0 { + return -1 + } + result = (result*32 + v) % 37 + } + return result +} + +// validateCheckChar verifies that canonical[15] is the correct Crockford +// mod-37 check symbol for canonical[0:15]. +func validateCheckChar(canonical string) error { + if len(canonical) != 16 { + return errors.New("idgen: invalid length for check validation") + } + expected := computeCheckValue(canonical) + if expected < 0 { + return errors.New("idgen: invalid data characters in code") + } + want := rune(crockfordCheck[expected]) + got := rune(canonical[15]) + if got != want { + return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got) + } + return nil +} + +// CanonicalizeCode converts an activation-code string into its canonical form: +// uppercase, with I/L→1 and O→0 substitutions applied, with the check +// character validated. Hyphens and spaces are stripped before validation +// (supports the common XXXX-XXXX-XXXX-XXXX display format). +// +// Returns an error if the string contains characters outside the normalised +// Crockford alphabet, if the length (after stripping) is not exactly 16, or +// if the check character is incorrect. +func CanonicalizeCode(code string) (string, error) { + code = strings.TrimSpace(code) + code = strings.ReplaceAll(code, "-", "") + code = strings.ReplaceAll(code, " ", "") + + if len(code) != 16 { + return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code)) + } + + var buf [16]byte + for i := 0; i < 16; i++ { + ch := code[i] + if ch >= 128 { + return "", fmt.Errorf("idgen: non-ASCII character at position %d", i) + } + v := crockfordDecode[ch] + if v < 0 { + if i < 15 { + return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i) + } + // Position 15 is the check character; validate separately below. + buf[i] = []byte(strings.ToUpper(string(ch)))[0] + continue + } + buf[i] = crockfordAlphabet[v] + } + + canonical := string(buf[:]) + if err := validateCheckChar(canonical); err != nil { + return "", err + } + return canonical, nil +} + +// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext +// activation code. This digest is the value stored in the database; the +// plaintext code itself must never be persisted or logged. +func HashCode(canonical string) string { + sum := sha256.Sum256([]byte(canonical)) + return hex.EncodeToString(sum[:]) +} + +// GenerateCode generates a single random activation code in canonical +// Crockford Base32 form: 15 random data characters followed by 1 mod-37 +// check character (16 characters total). Uses crypto/rand for +// cryptographically-secure randomness. +// +// To avoid modular bias, the function uses rejection sampling: random bytes +// in [0, 224) are accepted (224 = 7×32 ensures a uniform distribution over +// the 32-symbol alphabet), bytes ≥ 224 are discarded. +func GenerateCode() (string, error) { + const dataLen = 15 + var buf [dataLen]byte + i := 0 + for i < dataLen { + // Over-read to reduce crypto/rand syscall count. + var tmp [dataLen * 2]byte + if _, err := rand.Read(tmp[:]); err != nil { + return "", fmt.Errorf("idgen: crypto/rand: %w", err) + } + for _, b := range tmp { + if b < 224 { // accept range: 224 = 7×32 + buf[i] = crockfordAlphabet[b%32] + i++ + if i == dataLen { + break + } + } + } + } + + data := string(buf[:]) + // Compute check value over the 15 data chars (the dummy 16th char is ignored). + checkVal := computeCheckValue(data + "0") + if checkVal < 0 { + // Cannot happen: all buf[i] values come from crockfordAlphabet. + return "", errors.New("idgen: internal check computation error") + } + return data + string(crockfordCheck[checkVal]), nil +} diff --git a/server/internal/idgen/idgen_test.go b/server/internal/idgen/idgen_test.go new file mode 100644 index 0000000..d38c468 --- /dev/null +++ b/server/internal/idgen/idgen_test.go @@ -0,0 +1,244 @@ +package idgen_test + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/wangjia/pangolin/server/internal/idgen" +) + +// ───────────────────────────────────────────────────────────────────────────── +// UUID v7 tests +// ───────────────────────────────────────────────────────────────────────────── + +// TestNewReturnsVersion7 verifies that New() returns a UUID with version 7. +func TestNewReturnsVersion7(t *testing.T) { + for i := 0; i < 100; i++ { + id := idgen.New() + if id.Version() != uuid.Version(7) { + t.Fatalf("New(): version = %d, want 7", id.Version()) + } + } +} + +// TestNewStringFormat verifies that NewString() returns a properly formatted UUID string. +func TestNewStringFormat(t *testing.T) { + s := idgen.NewString() + // Standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars) + if len(s) != 36 { + t.Fatalf("NewString() length = %d, want 36", len(s)) + } + parts := strings.Split(s, "-") + if len(parts) != 5 { + t.Fatalf("NewString() has %d hyphen-separated parts, want 5", len(parts)) + } + expected := []int{8, 4, 4, 4, 12} + for i, p := range parts { + if len(p) != expected[i] { + t.Errorf("part %d: length = %d, want %d", i, len(p), expected[i]) + } + } +} + +// TestNewUniqueness verifies that 10 000 generated UUIDs are all distinct. +func TestNewUniqueness(t *testing.T) { + const n = 10_000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + s := idgen.NewString() + if _, dup := seen[s]; dup { + t.Fatalf("duplicate UUID at iteration %d: %s", i, s) + } + seen[s] = struct{}{} + } +} + +// TestNewTimeOrdered verifies that sequentially generated UUIDs are +// monotonically non-decreasing in their string representation (UUID v7 is +// time-ordered so lexicographic sort ≈ generation order). +func TestNewTimeOrdered(t *testing.T) { + prev := idgen.NewString() + for i := 0; i < 1000; i++ { + next := idgen.NewString() + if next < prev { + t.Fatalf("UUID ordering violation: %s > %s", prev, next) + } + prev = next + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Crockford Base32 activation-code tests +// ───────────────────────────────────────────────────────────────────────────── + +// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code +// composed entirely of the Crockford alphabet (15 data chars + 1 check char). +func TestGenerateCodeFormat(t *testing.T) { + for i := 0; i < 1000; i++ { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode error: %v", err) + } + if len(code) != 16 { + t.Errorf("code %q: length = %d, want 16", code, len(code)) + } + // A freshly generated code must canonicalize to itself. + canonical, err := idgen.CanonicalizeCode(code) + if err != nil { + t.Errorf("CanonicalizeCode(%q): %v", code, err) + } + if canonical != code { + t.Errorf("canonical form mismatch: got %q, want %q", canonical, code) + } + } +} + +// TestGenerateCodeUniqueness checks that 10 000 generated codes have no +// hash collisions (birthday probability ≈ 10^−8 for 75-bit codes). +func TestGenerateCodeUniqueness(t *testing.T) { + const n = 10_000 + seen := make(map[string]struct{}, n) + for i := 0; i < n; i++ { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + h := idgen.HashCode(code) + if _, dup := seen[h]; dup { + t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h) + } + seen[h] = struct{}{} + } +} + +// TestCanonicalizeCodeNormalization verifies I/L→1 and O→0 substitutions. +func TestCanonicalizeCodeNormalization(t *testing.T) { + base, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + + // Lower-case input must produce the upper-case canonical form. + lower := strings.ToLower(base) + canonical, err := idgen.CanonicalizeCode(lower) + if err != nil { + t.Errorf("CanonicalizeCode(lower) error: %v", err) + } + if canonical != base { + t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base) + } + + // I, L → 1. + idx := strings.IndexByte(base, '1') + if idx >= 0 && idx < 15 { + for _, sub := range []string{"I", "L", "i", "l"} { + variant := base[:idx] + sub + base[idx+1:] + c, err := idgen.CanonicalizeCode(variant) + if err != nil { + t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) + continue + } + if c != base { + t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) + } + } + } + + // O → 0. + idx = strings.IndexByte(base, '0') + if idx >= 0 && idx < 15 { + variant := base[:idx] + "O" + base[idx+1:] + c, err := idgen.CanonicalizeCode(variant) + if err != nil { + t.Errorf("CanonicalizeCode(%q) error: %v", variant, err) + } else if c != base { + t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base) + } + } +} + +// TestCheckCharDetectsSingleErrors verifies that mutating any single data +// character in a valid code causes CanonicalizeCode to return an error. +func TestCheckCharDetectsSingleErrors(t *testing.T) { + const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + + for pos := 0; pos < 15; pos++ { + original := rune(code[pos]) + for _, replacement := range alpha { + if replacement == original { + continue + } + mutated := code[:pos] + string(replacement) + code[pos+1:] + if _, err := idgen.CanonicalizeCode(mutated); err == nil { + t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q", + pos, original, replacement, code, mutated) + } + } + } +} + +// TestHashCodeConsistency verifies that HashCode is deterministic and that +// different codes produce different digests. +func TestHashCodeConsistency(t *testing.T) { + code1, _ := idgen.GenerateCode() + code2, _ := idgen.GenerateCode() + for code1 == code2 { + code2, _ = idgen.GenerateCode() + } + + h1a := idgen.HashCode(code1) + h1b := idgen.HashCode(code1) + h2 := idgen.HashCode(code2) + + if h1a != h1b { + t.Error("HashCode is not deterministic") + } + if h1a == h2 { + t.Error("different codes produced the same digest") + } + if len(h1a) != 64 { + t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a)) + } +} + +// TestCanonicalizeCodeRejectsInvalidLength tests length validation. +func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) { + cases := []string{"", "ABCDE", "ABCDEFGH12345678X"} + for _, c := range cases { + if _, err := idgen.CanonicalizeCode(c); err == nil { + t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c)) + } + } +} + +// TestCanonicalizeCodeRejectsInvalidChars tests that non-Crockford characters +// at data positions (0–14) are rejected. +func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) { + base, _ := idgen.GenerateCode() + invalid := "!" + base[1:] + if _, err := idgen.CanonicalizeCode(invalid); err == nil { + t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid) + } +} + +// TestHyphenStripping verifies that hyphens inserted for display readability +// (e.g. XXXX-XXXX-XXXX-XXXX) are stripped before validation. +func TestHyphenStripping(t *testing.T) { + code, err := idgen.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:] + canonical, err := idgen.CanonicalizeCode(hyphenated) + if err != nil { + t.Errorf("CanonicalizeCode(hyphenated) error: %v", err) + } + if canonical != code { + t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code) + } +}