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>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user