Files
pangolin/server/internal/idgen/idgen.go
T
wangjia b64c002a33 feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr:
- Add New() constructor, StatusFor() HTTP-status mapping
- Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors
- Add chi-compatible Middleware for panic(*Error) → JSON recovery
- Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware)

idgen:
- Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0)
- Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes)
- Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check)

codes:
- Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen
- All existing codes generator tests continue to pass unchanged

server:
- Add CONVENTIONS.md covering package structure, error handling, ID generation,
  database conventions, handler templates, auth context, testing, and logging rules
- Move google/uuid from indirect to direct dependency in go.mod

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:58:38 +08:00

207 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 031 match crockfordAlphabet;
// symbols 3236 are *, ~, $, =, U.
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
// crockfordDecode maps every printable ASCII character to its Crockford
// numeric value (031), 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
}