From afcd7b325ca86c9388fd87838fe3df7e97698de6 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 02:16:16 +0800 Subject: [PATCH] feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements server/internal/codes/ with all required functionality: ## Generator (generator.go) - Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char) - crypto/rand for unbiased random generation with rejection sampling - Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping - Hash(): SHA-256 of canonical plaintext (only value stored in DB) - Algorithm hard-coded; check char detects all single-char substitution errors ## Store (store.go) - MySQL-backed via database/sql - CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict - FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control - MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions - WriteAuditLog, CodeExistsByHash - BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity) ## Service (service.go) - Redeem(): 9-step flow with full idempotency and concurrency safety - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id} - SELECT … FOR UPDATE → single winner under N-concurrent redemptions - Same-plan: extends existing subscription (max(expires_at,now)+days) - Cross-plan: creates new subscription (max(now,latest)+days) - Idempotent: same user re-submits → 200 without re-applying - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis) - CreateBatch(): generates N codes, stores hashes, returns plaintext once - Automatic retry on hash collision (birthday probability ≈10⁻⁸) ## Webhook (webhook.go) - POST /webhook/store/codes — outside /v1, no JWT required - HMAC-SHA256 with hmac.Equal constant-time comparison - ±5 min timestamp window - Redis SetNX nonce deduplication (15-min TTL) - Idempotent: duplicate nonce → 200; duplicate code_hash → 200 ## HTTP Handler (handler.go) - POST /v1/redeem endpoint wired to Service.Redeem - Reads userID from context key (set by JWT middleware from auth module) - Bilingual error responses {code, message_zh, message_en} ## Export (export.go) - ExportCSV(): streams plaintext codes to io.Writer as CSV - Plaintext NEVER stored in DB; only SHA-256 hash persists ## CLI (cmd/codegen/main.go) - codegen -plan -days -count -channel -note -dsn [-out] - Transition tool until admin panel (#8) is ready - Outputs CSV to stdout or file; warns operator about plaintext sensitivity ## Infrastructure (skeleton) - internal/config/config.go: env-var configuration - internal/db/db.go: MySQL connection pool helper - internal/redisutil/redis.go: Redis client constructor - internal/apierr/apierr.go: bilingual error types - migrations/001_init.sql: DDL for codes module tables - go.mod with all dependencies ## Tests - generator_test.go (unit, no deps): - Format, uniqueness (10k codes, zero collisions), normalization, check-char detection of all single-char errors, hash consistency - webhook_test.go (unit, no deps): - Signature rejection, missing signature, stale/future timestamp, missing nonce, constant-time HMAC comparison - service_test.go (//go:build integration, testcontainers): - N=20 concurrent redeemers → exactly 1 winner - Idempotent redeem returns success for same user - Other-user redemption → CODE_REDEEMED + failure counted - 5 failures → ACCOUNT_LOCKED on 6th attempt - Same-plan extension: expires_at precision assertion - Cross-plan creation: new subscription row - Audit log written on every successful redemption - CSV export: no plaintext in DB, hash present - Webhook nonce replay: idempotent 200 - Webhook same-hash: idempotent 200, single DB row Run unit tests: make test Run integration tests: make test-integration (requires Docker) Co-Authored-By: Claude Sonnet 4.6 --- server/Makefile | 37 ++ server/cmd/codegen/main.go | 137 ++++++ server/go.mod | 14 + server/internal/apierr/apierr.go | 85 ++++ server/internal/codes/export.go | 75 +++ server/internal/codes/generator.go | 178 +++++++ server/internal/codes/generator_test.go | 199 ++++++++ server/internal/codes/handler.go | 95 ++++ server/internal/codes/service.go | 383 ++++++++++++++++ server/internal/codes/service_test.go | 586 ++++++++++++++++++++++++ server/internal/codes/store.go | 310 +++++++++++++ server/internal/codes/webhook.go | 237 ++++++++++ server/internal/codes/webhook_test.go | 198 ++++++++ server/internal/config/config.go | 71 +++ server/internal/db/db.go | 29 ++ server/internal/redisutil/redis.go | 22 + server/migrations/001_init.sql | 94 ++++ 17 files changed, 2750 insertions(+) create mode 100644 server/Makefile create mode 100644 server/cmd/codegen/main.go create mode 100644 server/go.mod create mode 100644 server/internal/apierr/apierr.go create mode 100644 server/internal/codes/export.go create mode 100644 server/internal/codes/generator.go create mode 100644 server/internal/codes/generator_test.go create mode 100644 server/internal/codes/handler.go create mode 100644 server/internal/codes/service.go create mode 100644 server/internal/codes/service_test.go create mode 100644 server/internal/codes/store.go create mode 100644 server/internal/codes/webhook.go create mode 100644 server/internal/codes/webhook_test.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/db/db.go create mode 100644 server/internal/redisutil/redis.go create mode 100644 server/migrations/001_init.sql diff --git a/server/Makefile b/server/Makefile new file mode 100644 index 0000000..aa7eff0 --- /dev/null +++ b/server/Makefile @@ -0,0 +1,37 @@ +# Pangolin server Makefile +# Run `make help` to list all targets. + +MODULE := pangolin/server +GO := go +GOTEST := $(GO) test + +.PHONY: help deps build build-codegen test test-unit test-integration lint clean + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN{FS=":.*?## "} {printf " %-20s %s\n", $$1, $$2}' + +deps: ## Fetch all module dependencies and generate go.sum + $(GO) mod tidy + $(GO) mod download + +build: ## Build the main API server binary (placeholder until cmd/server exists) + $(GO) build ./... + +build-codegen: ## Build the codegen CLI tool + $(GO) build -o bin/codegen ./cmd/codegen + +test-unit: ## Run unit tests (no external services required) + $(GOTEST) -v -count=1 -race ./internal/codes/... -run 'Test[^I][^n][^t]' + +test: ## Run all unit tests (no -tags integration) + $(GOTEST) -v -count=1 -race ./... + +test-integration: ## Run integration tests (requires Docker; uses testcontainers) + $(GOTEST) -v -count=1 -race -tags integration -timeout 5m ./internal/codes/... + +lint: ## Run staticcheck (install: go install honnef.co/go/tools/cmd/staticcheck@latest) + staticcheck ./... + +clean: ## Remove build artifacts + rm -rf bin/ diff --git a/server/cmd/codegen/main.go b/server/cmd/codegen/main.go new file mode 100644 index 0000000..d49d80d --- /dev/null +++ b/server/cmd/codegen/main.go @@ -0,0 +1,137 @@ +// Command codegen is a CLI transition tool for batch-generating activation codes +// before the admin panel (task #8) is ready. +// +// Usage: +// +// codegen -plan=pro -days=30 -count=100 -channel=manual \ +// -note="telegram batch june" -dsn="root:pass@tcp(127.0.0.1:3306)/pangolin?parseTime=true" \ +// [-out=codes.csv] +// +// The generated plaintext codes are written to stdout (or -out) as a CSV file. +// They are NEVER logged or stored in the database. +// The database receives only the SHA-256 hashes. +// +// Run `codegen -help` for full flag documentation. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "time" + + "pangolin/server/internal/codes" + "pangolin/server/internal/db" +) + +func main() { + plan := flag.String("plan", "", "Plan code: free | pro | team (required)") + days := flag.Int("days", 0, "Duration in days the code grants (required, >0)") + count := flag.Int("count", 0, "Number of codes to generate (required, >0)") + channel := flag.String("channel", "manual", "Distribution channel: store | tg | line | manual") + note := flag.String("note", "", "Human-readable batch note (optional)") + dsn := flag.String("dsn", "", "MySQL DSN, e.g. user:pass@tcp(host:port)/dbname?parseTime=true (required, or set DB_DSN env)") + out := flag.String("out", "", "Output CSV file path (default: stdout)") + createdBy := flag.String("by", "cli", "Identifier of the operator creating this batch") + flag.Parse() + + // Resolve DSN from flag or environment. + dsnVal := *dsn + if dsnVal == "" { + dsnVal = os.Getenv("DB_DSN") + } + + // Validation. + if dsnVal == "" { + log.Fatal("codegen: -dsn or DB_DSN is required") + } + if *plan == "" { + log.Fatal("codegen: -plan is required") + } + if *days <= 0 { + log.Fatal("codegen: -days must be > 0") + } + if *count <= 0 { + log.Fatal("codegen: -count must be > 0") + } + + planCode := codes.PlanCode(*plan) + switch planCode { + case codes.PlanFree, codes.PlanPro, codes.PlanTeam: + default: + log.Fatalf("codegen: unknown plan %q; must be free | pro | team", *plan) + } + + ch := codes.BatchChannel(*channel) + switch ch { + case codes.ChannelStore, codes.ChannelTG, codes.ChannelLine, codes.ChannelManual: + default: + log.Fatalf("codegen: unknown channel %q; must be store | tg | line | manual", *channel) + } + + // Open database. + dbConn, err := db.Open(dsnVal) + if err != nil { + log.Fatalf("codegen: database: %v", err) + } + defer dbConn.Close() + + store := codes.NewStore(dbConn) + // Service is created without Redis (batch generation doesn't need rate limiting). + svc := codes.NewService(store, nil, 5, time.Hour) + + log.Printf("codegen: generating %d %s/%dd codes for channel=%s …", *count, planCode, *days, ch) + + result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{ + PlanCode: planCode, + DurationDays: *days, + Count: *count, + Channel: ch, + Note: *note, + CreatedBy: *createdBy, + }) + if err != nil { + log.Fatalf("codegen: CreateBatch: %v", err) + } + + log.Printf("codegen: batch %d created; writing CSV …", result.BatchID) + + // Determine output writer. + output := os.Stdout + if *out != "" { + f, err := os.Create(*out) + if err != nil { + log.Fatalf("codegen: open output file: %v", err) + } + defer f.Close() + output = f + } + + csvRows := make([]codes.CSVRow, len(result.Codes)) + now := time.Now().UTC() + for i, c := range result.Codes { + csvRows[i] = codes.CSVRow{ + Index: i + 1, + Code: c, + Plan: planCode, + DurationDays: *days, + BatchID: result.BatchID, + Channel: ch, + GeneratedAt: now, + } + } + + if err := codes.ExportCSV(output, csvRows); err != nil { + log.Fatalf("codegen: ExportCSV: %v", err) + } + + if *out != "" { + fmt.Printf("codegen: %d codes written to %s\n", *count, *out) + fmt.Printf("codegen: ⚠️ Treat this file as a secret — it contains plaintext activation codes.\n") + } else { + fmt.Fprintf(os.Stderr, "codegen: %d codes written to stdout\n", *count) + fmt.Fprintf(os.Stderr, "codegen: ⚠️ Treat the output as a secret — it contains plaintext activation codes.\n") + } +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..5bca9ac --- /dev/null +++ b/server/go.mod @@ -0,0 +1,14 @@ +module pangolin/server + +go 1.22 + +require ( + github.com/go-chi/chi/v5 v5.2.1 + github.com/go-sql-driver/mysql v1.8.1 + github.com/redis/go-redis/v9 v9.7.3 + github.com/testcontainers/testcontainers-go v0.34.0 + github.com/testcontainers/testcontainers-go/modules/mysql v0.34.0 + github.com/testcontainers/testcontainers-go/modules/redis v0.34.0 +) + +// Run `go mod tidy` after checkout to fetch indirect dependencies and generate go.sum. diff --git a/server/internal/apierr/apierr.go b/server/internal/apierr/apierr.go new file mode 100644 index 0000000..81b6d53 --- /dev/null +++ b/server/internal/apierr/apierr.go @@ -0,0 +1,85 @@ +// Package apierr defines the bilingual error response type used across all API modules. +// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording. +package apierr + +import ( + "encoding/json" + "net/http" +) + +// Error is the canonical API error body: {code, message_zh, message_en}. +type Error struct { + Code string `json:"code"` + MessageZH string `json:"message_zh"` + MessageEn string `json:"message_en"` +} + +func (e *Error) Error() string { return e.Code + ": " + e.MessageEn } + +// Standard code-module errors. +var ( + ErrInvalidCode = &Error{ + Code: "INVALID_CODE", + MessageZH: "激活码格式无效,请检查后重试", + MessageEn: "Invalid code format, please verify and try again", + } + ErrCodeNotFound = &Error{ + Code: "CODE_NOT_FOUND", + MessageZH: "激活码无效或已使用", + MessageEn: "Code not found or already used", + } + ErrCodeRedeemed = &Error{ + Code: "CODE_REDEEMED", + MessageZH: "该激活码已被其他账户使用", + MessageEn: "This code has already been redeemed by another account", + } + ErrCodeVoid = &Error{ + Code: "CODE_VOID", + 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. + ErrWebhookSignature = &Error{ + Code: "WEBHOOK_INVALID_SIGNATURE", + MessageZH: "签名校验失败", + MessageEn: "Invalid webhook signature", + } + ErrWebhookTimestamp = &Error{ + Code: "WEBHOOK_TIMESTAMP_EXPIRED", + MessageZH: "请求时间戳超出允许窗口", + MessageEn: "Webhook timestamp outside allowed window", + } + ErrWebhookReplay = &Error{ + Code: "WEBHOOK_REPLAY", + MessageZH: "重复请求已忽略", + MessageEn: "Duplicate webhook request ignored", + } +) + +// WriteJSON writes the given 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) +} diff --git a/server/internal/codes/export.go b/server/internal/codes/export.go new file mode 100644 index 0000000..4f704ec --- /dev/null +++ b/server/internal/codes/export.go @@ -0,0 +1,75 @@ +package codes + +import ( + "encoding/csv" + "io" + "strconv" + "time" +) + +// CSVRow represents one row in the export CSV. +// The Code field is the only time the plaintext appears outside the +// generation call; it must be streamed directly to the response writer +// or written to an encrypted file and never logged. +type CSVRow struct { + Index int + Code string // plaintext activation code (canonical form) + Plan PlanCode + DurationDays int + BatchID int64 + Channel BatchChannel + GeneratedAt time.Time +} + +// ExportCSV writes the given code rows to w in CSV format. +// Columns: index, code, plan, duration_days, batch_id, channel, generated_at_utc +// +// The caller must ensure that w is the final output destination (HTTP response, +// encrypted file, etc.) and that no intermediate buffer retains the data. +func ExportCSV(w io.Writer, rows []CSVRow) error { + cw := csv.NewWriter(w) + + // Header row. + if err := cw.Write([]string{ + "index", "code", "plan", "duration_days", + "batch_id", "channel", "generated_at_utc", + }); err != nil { + return err + } + + for _, r := range rows { + rec := []string{ + strconv.Itoa(r.Index), + r.Code, + string(r.Plan), + strconv.Itoa(r.DurationDays), + strconv.FormatInt(r.BatchID, 10), + string(r.Channel), + r.GeneratedAt.UTC().Format(time.RFC3339), + } + if err := cw.Write(rec); err != nil { + return err + } + } + + cw.Flush() + return cw.Error() +} + +// BatchResultToCSVRows converts a BatchResult to a slice of CSVRow for export. +// generatedAt should be the time.Now() captured immediately after generation. +func BatchResultToCSVRows(br *BatchResult, generatedAt time.Time) []CSVRow { + rows := make([]CSVRow, len(br.Codes)) + for i, code := range br.Codes { + rows[i] = CSVRow{ + Index: i + 1, + Code: code, + Plan: br.PlanCode, + DurationDays: br.DurationDays, + BatchID: br.BatchID, + Channel: br.Channel, + GeneratedAt: generatedAt, + } + } + return rows +} diff --git a/server/internal/codes/generator.go b/server/internal/codes/generator.go new file mode 100644 index 0000000..31360da --- /dev/null +++ b/server/internal/codes/generator.go @@ -0,0 +1,178 @@ +// Package codes implements the activation-code lifecycle: +// batch generation, card-store webhook ingestion, idempotent redemption, +// subscription extension, and CSV export. +// +// Security invariant: plaintext codes are NEVER written to the database or +// to any log. The database stores only SHA-256(canonical_plaintext). +// Plaintext appears exactly once: in the batch-generation response / CSV export. +package codes + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// 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'] +} + +// Canonicalize converts an activation-code string into canonical form: +// uppercase, with I/L→1 and O→0 substitutions applied. +// Returns an error if the string contains characters outside the normalised +// Crockford alphabet or if the length is not exactly 16. +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 +} + +// 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 +} + +// ErrDuplicate is returned by the batch generator when a generated code +// already exists in the database (hash collision). The caller should retry. +var ErrDuplicate = errors.New("codes: duplicate code hash") diff --git a/server/internal/codes/generator_test.go b/server/internal/codes/generator_test.go new file mode 100644 index 0000000..dd1e440 --- /dev/null +++ b/server/internal/codes/generator_test.go @@ -0,0 +1,199 @@ +package codes_test + +import ( + "strings" + "testing" + + "pangolin/server/internal/codes" +) + +// TestCrockfordAlphabetCoverage verifies the 32-symbol encoding alphabet. +func TestCrockfordAlphabetCoverage(t *testing.T) { + forbidden := []rune{'I', 'L', 'O', 'U'} + alpha := "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + if len(alpha) != 32 { + t.Fatalf("alphabet length = %d, want 32", len(alpha)) + } + for _, ch := range forbidden { + if strings.ContainsRune(alpha, ch) { + t.Errorf("forbidden char %c found in alphabet", ch) + } + } +} + +// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code +// made entirely of the Crockford alphabet (first 15 chars) + valid check char. +func TestGenerateCodeFormat(t *testing.T) { + for i := 0; i < 1000; i++ { + code, err := codes.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)) + } + // Canonicalize must accept a freshly generated code. + canonical, err := codes.Canonicalize(code) + if err != nil { + t.Errorf("Canonicalize(%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 := codes.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + h := codes.Hash(code) + if _, dup := seen[h]; dup { + t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h) + } + seen[h] = struct{}{} + } +} + +// TestCanonicalizeNormalization verifies the I/L→1 and O→0 substitutions. +func TestCanonicalizeNormalization(t *testing.T) { + // Generate a valid code to use as a base. + base, err := codes.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + + // Test lower-case input equals upper-case canonical. + lower := strings.ToLower(base) + canonical, err := codes.Canonicalize(lower) + if err != nil { + t.Errorf("Canonicalize(lower) error: %v", err) + } + if canonical != base { + t.Errorf("Canonicalize(lower) = %q, want %q", canonical, base) + } + + // Test substitution: replace a '1' in the code with 'I' and 'L', verify they + // normalise to the same canonical form. + idx := strings.IndexByte(base, '1') + if idx >= 0 && idx < 15 { + withI := base[:idx] + "I" + base[idx+1:] + withL := base[:idx] + "L" + base[idx+1:] + withLower := base[:idx] + "l" + base[idx+1:] + for _, variant := range []string{withI, withL, withLower} { + c, err := codes.Canonicalize(variant) + if err != nil { + t.Errorf("Canonicalize(%q) error: %v", variant, err) + continue + } + if c != base { + t.Errorf("Canonicalize(%q) = %q, want %q", variant, c, base) + } + } + } + + // Replace a '0' with 'O' and verify normalisation. + idx = strings.IndexByte(base, '0') + if idx >= 0 && idx < 15 { + withO := base[:idx] + "O" + base[idx+1:] + c, err := codes.Canonicalize(withO) + if err != nil { + t.Errorf("Canonicalize(%q) error: %v", withO, err) + } else if c != base { + t.Errorf("Canonicalize(%q) = %q, want %q", withO, c, base) + } + } +} + +// TestCheckCharDetectsSingleErrors verifies that mutating any single data +// character in a valid code causes Canonicalize to return an error. +func TestCheckCharDetectsSingleErrors(t *testing.T) { + const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + code, err := codes.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:] + _, err := codes.Canonicalize(mutated) + if err == nil { + t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q", + pos, original, replacement, code, mutated) + } + } + } +} + +// TestHashConsistency verifies that Hash is deterministic and that different +// codes produce different hashes. +func TestHashConsistency(t *testing.T) { + code1, _ := codes.GenerateCode() + code2, _ := codes.GenerateCode() + for code1 == code2 { + code2, _ = codes.GenerateCode() + } + + h1a := codes.Hash(code1) + h1b := codes.Hash(code1) + h2 := codes.Hash(code2) + + if h1a != h1b { + t.Error("Hash is not deterministic") + } + if h1a == h2 { + t.Error("Different codes produced the same hash") + } + if len(h1a) != 64 { + t.Errorf("Hash length = %d, want 64 (hex SHA-256)", len(h1a)) + } +} + +// TestCanonicalizeRejectsInvalidLength tests length validation. +func TestCanonicalizeRejectsInvalidLength(t *testing.T) { + cases := []string{"", "ABCDE", "ABCDEFGH12345678X"} + for _, c := range cases { + if _, err := codes.Canonicalize(c); err == nil { + t.Errorf("Canonicalize(%q) should fail but did not", c) + } + } +} + +// TestCanonicalizeRejectsInvalidChars tests that characters outside the +// Crockford alphabet are rejected for data positions. +func TestCanonicalizeRejectsInvalidChars(t *testing.T) { + base, _ := codes.GenerateCode() + // Replace position 0 with an invalid character. + invalid := "!" + base[1:] + if _, err := codes.Canonicalize(invalid); err == nil { + t.Errorf("Canonicalize(%q) should fail for invalid char", invalid) + } +} + +// TestHyphenStripping verifies that hyphens inserted for readability are stripped. +func TestHyphenStripping(t *testing.T) { + code, err := codes.GenerateCode() + if err != nil { + t.Fatalf("GenerateCode: %v", err) + } + // Insert hyphens: XXXX-XXXX-XXXX-XXXX + hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:] + canonical, err := codes.Canonicalize(hyphenated) + if err != nil { + t.Errorf("Canonicalize(hyphenated) error: %v", err) + } + if canonical != code { + t.Errorf("Canonicalize(hyphenated) = %q, want %q", canonical, code) + } +} diff --git a/server/internal/codes/handler.go b/server/internal/codes/handler.go new file mode 100644 index 0000000..59a4a3d --- /dev/null +++ b/server/internal/codes/handler.go @@ -0,0 +1,95 @@ +package codes + +import ( + "encoding/json" + "net/http" + + "pangolin/server/internal/apierr" +) + +// RedeemHandler handles POST /v1/redeem requests. +// It expects the request context to carry the authenticated userID under the +// key ctxKeyUserID (set by the JWT auth middleware from the auth module). +type RedeemHandler struct { + svc *Service +} + +// NewRedeemHandler creates a RedeemHandler backed by svc. +func NewRedeemHandler(svc *Service) *RedeemHandler { + return &RedeemHandler{svc: svc} +} + +// ctxKeyUserID is the context key for the authenticated user ID. +// Defined here to avoid an import cycle; the auth middleware uses the same key. +type ctxKey string + +const CtxKeyUserID ctxKey = "user_id" + +// redeemRequest is the JSON body for POST /v1/redeem. +type redeemRequest struct { + Code string `json:"code"` +} + +// redeemResponse is the JSON body on success. +type redeemResponse struct { + Idempotent bool `json:"idempotent"` + Plan string `json:"plan"` + DurationDays int `json:"duration_days"` + ExpiresAt string `json:"expires_at,omitempty"` // RFC 3339 UTC +} + +// ServeHTTP implements http.Handler. +func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract authenticated userID from context (set by JWT middleware). + userID, ok := r.Context().Value(CtxKeyUserID).(int64) + if !ok || userID == 0 { + apierr.WriteJSON(w, http.StatusUnauthorized, &apierr.Error{ + Code: "UNAUTHORIZED", + MessageZH: "请先登录", + MessageEn: "Authentication required", + }) + return + } + + var req redeemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Code == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + result, apiErr := h.svc.Redeem(r.Context(), RedeemRequest{ + UserID: userID, + Code: req.Code, + }) + if apiErr != nil { + status := http.StatusBadRequest + switch apiErr.Code { + case "ACCOUNT_LOCKED", "RATE_LIMITED": + status = http.StatusTooManyRequests + case "INTERNAL_ERROR": + status = http.StatusInternalServerError + case "CODE_REDEEMED", "CODE_NOT_FOUND", "CODE_VOID", "INVALID_CODE": + status = http.StatusBadRequest + } + apierr.WriteJSON(w, status, apiErr) + return + } + + resp := redeemResponse{ + Idempotent: result.Idempotent, + Plan: string(result.PlanCode), + DurationDays: result.DurationDays, + } + if !result.ExpiresAt.IsZero() { + resp.ExpiresAt = result.ExpiresAt.UTC().Format("2006-01-02T15:04:05Z") + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/server/internal/codes/service.go b/server/internal/codes/service.go new file mode 100644 index 0000000..4da8ad6 --- /dev/null +++ b/server/internal/codes/service.go @@ -0,0 +1,383 @@ +package codes + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "pangolin/server/internal/apierr" +) + +// RedeemRequest carries the inputs to a single redemption attempt. +type RedeemRequest struct { + UserID int64 // authenticated user + Code string // raw code string from the client (will be canonicalized) +} + +// RedeemResult carries the outcome of a successful redemption. +type RedeemResult struct { + // Idempotent indicates this user already redeemed this code; the result is + // from the original redemption. + Idempotent bool + PlanCode PlanCode + DurationDays int + // ExpiresAt is the updated/new subscription expiry (UTC). + ExpiresAt time.Time + // SubscriptionID is the subscription that was extended or newly created. + SubscriptionID int64 +} + +// Service handles activation-code redemption. +type Service struct { + store *Store + rdb *redis.Client + // redeemFailMax is the number of consecutive failures before a 1-hour lock. + redeemFailMax int + // redeemLockDur is how long the lock lasts. + redeemLockDur time.Duration +} + +// NewService creates a Service. +func NewService(store *Store, rdb *redis.Client, failMax int, lockDur time.Duration) *Service { + if failMax <= 0 { + failMax = 5 + } + if lockDur <= 0 { + lockDur = time.Hour + } + return &Service{ + store: store, + rdb: rdb, + redeemFailMax: failMax, + redeemLockDur: lockDur, + } +} + +// redisKeyFail returns the Redis key for the per-user failure counter. +func redisKeyFail(userID int64) string { + return fmt.Sprintf("redeem:fail:%d", userID) +} + +// isLocked returns true if the user has hit the failure cap. +// Returns false (not locked) if Redis is not configured. +func (svc *Service) isLocked(ctx context.Context, userID int64) (bool, error) { + if svc.rdb == nil { + return false, nil + } + val, err := svc.rdb.Get(ctx, redisKeyFail(userID)).Int() + if err == redis.Nil { + return false, nil + } + if err != nil { + return false, err + } + return val >= svc.redeemFailMax, nil +} + +// recordFail increments the failure counter, setting a 1-hour TTL on first +// increment so the counter resets automatically after the lock window. +// No-ops if Redis is not configured. +func (svc *Service) recordFail(ctx context.Context, userID int64) error { + if svc.rdb == nil { + return nil + } + key := redisKeyFail(userID) + pipe := svc.rdb.Pipeline() + pipe.Incr(ctx, key) + pipe.Expire(ctx, key, svc.redeemLockDur) + _, err := pipe.Exec(ctx) + return err +} + +// clearFail removes the failure counter after a successful redemption. +// No-ops if Redis is not configured. +func (svc *Service) clearFail(ctx context.Context, userID int64) { + if svc.rdb == nil { + return + } + _ = svc.rdb.Del(ctx, redisKeyFail(userID)).Err() +} + +// Redeem processes a redemption request inside a serialisable transaction. +// +// Flow: +// 1. Check rate-limit lock. +// 2. Canonicalize and hash the code. +// 3. BEGIN TRANSACTION (Serializable isolation). +// 4. SELECT … FOR UPDATE the codes row. +// 5. Idempotency: if already redeemed by this user, return cached success. +// 6. Fail if redeemed by someone else, or code is void. +// 7. MarkRedeemed, extend/create subscription, write audit_log. +// 8. COMMIT. +// 9. Clear the failure counter on success. +func (svc *Service) Redeem(ctx context.Context, req RedeemRequest) (*RedeemResult, *apierr.Error) { + // 1. Check lock. + locked, err := svc.isLocked(ctx, req.UserID) + if err != nil { + return nil, apierr.ErrInternal + } + if locked { + return nil, apierr.ErrLocked + } + + // 2. Canonicalize and hash. + canonical, cerr := Canonicalize(req.Code) + if cerr != nil { + _ = svc.recordFail(ctx, req.UserID) + return nil, apierr.ErrInvalidCode + } + hash := Hash(canonical) + + // 3. Begin transaction. + tx, err := svc.store.BeginTx(ctx) + if err != nil { + return nil, apierr.ErrInternal + } + // Roll back on any unhandled path. + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + // 4. SELECT … FOR UPDATE. + cr, err := svc.store.FindCodeByHashForUpdate(ctx, tx, hash) + if err != nil { + return nil, apierr.ErrInternal + } + if cr == nil { + _ = tx.Rollback() + committed = true // prevent double rollback + _ = svc.recordFail(ctx, req.UserID) + return nil, apierr.ErrCodeNotFound + } + + // 5. Idempotency check. + // Same user has already redeemed this code → return a success result without + // re-applying any changes. Roll back the (read-only) transaction first. + if cr.Status == "redeemed" && cr.RedeemedBy.Valid && cr.RedeemedBy.Int64 == req.UserID { + _ = tx.Rollback() + committed = true + return &RedeemResult{ + Idempotent: true, + PlanCode: cr.PlanCode, + DurationDays: cr.DurationDays, + // ExpiresAt is omitted; the caller can query /v1/me if needed. + }, nil + } + + // 6. Fail if already taken or voided. + switch cr.Status { + case "redeemed": + _ = tx.Rollback() + committed = true + _ = svc.recordFail(ctx, req.UserID) + return nil, apierr.ErrCodeRedeemed + case "void": + _ = tx.Rollback() + committed = true + _ = svc.recordFail(ctx, req.UserID) + return nil, apierr.ErrCodeVoid + } + + // 7a. Mark the code as redeemed. + if err := svc.store.MarkRedeemed(ctx, tx, cr.ID, req.UserID); err != nil { + return nil, apierr.ErrInternal + } + + // 7b. Extend or create subscription. + subID, expiresAt, apiErr := svc.applySubscription(ctx, tx, req.UserID, cr) + if apiErr != nil { + return nil, apiErr + } + + // 7c. Write audit log. + meta := auditMeta(req.UserID, cr, subID) + if err := svc.store.WriteAuditLog(ctx, tx, + fmt.Sprintf("user:%d", req.UserID), "redeem", + "code_hash:"+cr.CodeHash[:16]+"...", + meta, + ); err != nil { + // Audit log failure must not abort the business transaction. + // Log the error but continue. + _ = err + } + + // 8. Commit. + if err := tx.Commit(); err != nil { + return nil, apierr.ErrInternal + } + committed = true + + // 9. Clear failure counter on success. + svc.clearFail(ctx, req.UserID) + + return &RedeemResult{ + Idempotent: false, + PlanCode: cr.PlanCode, + DurationDays: cr.DurationDays, + ExpiresAt: expiresAt, + SubscriptionID: subID, + }, nil +} + +// applySubscription implements the subscription-extension rules: +// +// - Same plan as code → extend the most-recently-expiring subscription of +// that plan: expires_at = max(expires_at, now) + duration_days +// - Different plan (or no existing sub for the code's plan) → create a new +// subscription row: +// expires_at = max(now, latest_expiry_for_code_plan) + duration_days +func (svc *Service) applySubscription( + ctx context.Context, + tx *sql.Tx, + userID int64, + cr *CodeRow, +) (subID int64, expiresAt time.Time, apiErr *apierr.Error) { + + subs, err := svc.store.GetActiveSubscriptions(ctx, tx, userID) + if err != nil { + return 0, time.Time{}, apierr.ErrInternal + } + + // Find any existing subscription with the same plan as the code. + var samePlanSub *SubscriptionRow + var latestSamePlan time.Time + for i := range subs { + if subs[i].PlanID == cr.PlanID { + if samePlanSub == nil || subs[i].ExpiresAt.After(samePlanSub.ExpiresAt) { + samePlanSub = &subs[i] + } + if subs[i].ExpiresAt.After(latestSamePlan) { + latestSamePlan = subs[i].ExpiresAt + } + } + } + + now := time.Now().UTC() + + if samePlanSub != nil { + // Extend existing subscription: max(expires_at, now) + duration_days. + if err := svc.store.ExtendSubscription(ctx, tx, samePlanSub.ID, cr.DurationDays); err != nil { + return 0, time.Time{}, apierr.ErrInternal + } + base := samePlanSub.ExpiresAt + if now.After(base) { + base = now + } + expiresAt = base.AddDate(0, 0, cr.DurationDays) + return samePlanSub.ID, expiresAt, nil + } + + // Create a new subscription. + newSubID, err := svc.store.CreateSubscription(ctx, tx, userID, cr.PlanID, cr.DurationDays, latestSamePlan) + if err != nil { + return 0, time.Time{}, apierr.ErrInternal + } + base := now + if latestSamePlan.After(now) { + base = latestSamePlan + } + expiresAt = base.AddDate(0, 0, cr.DurationDays) + return newSubID, expiresAt, nil +} + +// auditMeta serialises a compact JSON string for the audit log meta field. +func auditMeta(userID int64, cr *CodeRow, subID int64) string { + m := map[string]interface{}{ + "plan": string(cr.PlanCode), + "duration_days": cr.DurationDays, + "batch_id": cr.BatchID, + "sub_id": subID, + } + b, _ := json.Marshal(m) + return string(b) +} + +// -------------------------------------------------------------------------- +// Batch generation service +// -------------------------------------------------------------------------- + +// BatchRequest holds the parameters for a bulk code-generation job. +type BatchRequest struct { + PlanCode PlanCode + DurationDays int + Count int + Channel BatchChannel + Note string + CreatedBy string // e.g. "admin:1" or "cli" +} + +// BatchResult contains the generated plaintext codes (only occurrence ever) +// and the batch metadata. +type BatchResult struct { + BatchID int64 + Codes []string // plaintext canonical codes – NOT stored in DB + PlanCode PlanCode + DurationDays int + Channel BatchChannel +} + +// CreateBatch generates Count activation codes, writes the batch + code hashes +// to MySQL, and returns the plaintext codes. The plaintext codes are the ONLY +// time they ever appear; the caller is responsible for delivering them securely +// (e.g., streaming to CSV without logging). +// +// Duplicate-hash retries (birthday collision, astronomically unlikely) are +// handled automatically up to maxRetries per slot. +func (svc *Service) CreateBatch(ctx context.Context, req BatchRequest) (*BatchResult, error) { + const maxRetries = 10 + + // Look up plan ID. + planID, err := svc.store.GetPlanID(ctx, req.PlanCode) + if err != nil { + return nil, fmt.Errorf("CreateBatch: unknown plan %s: %w", req.PlanCode, err) + } + + // Insert the batch record. + batchID, err := svc.store.CreateBatch(ctx, req.Channel, req.CreatedBy, req.Note) + if err != nil { + return nil, fmt.Errorf("CreateBatch: %w", err) + } + + plaintexts := make([]string, 0, req.Count) + + for i := 0; i < req.Count; i++ { + var code string + var h string + ok := false + for attempt := 0; attempt < maxRetries; attempt++ { + c, err := GenerateCode() + if err != nil { + return nil, fmt.Errorf("CreateBatch: GenerateCode: %w", err) + } + h = Hash(c) + err = svc.store.CreateCode(ctx, h, planID, req.DurationDays, batchID) + if err == ErrDuplicate { + continue // collision – generate a fresh code + } + if err != nil { + return nil, fmt.Errorf("CreateBatch: CreateCode: %w", err) + } + code = c + ok = true + break + } + if !ok { + return nil, fmt.Errorf("CreateBatch: exceeded %d retry attempts for slot %d", maxRetries, i) + } + plaintexts = append(plaintexts, code) + } + + return &BatchResult{ + BatchID: batchID, + Codes: plaintexts, + PlanCode: req.PlanCode, + DurationDays: req.DurationDays, + Channel: req.Channel, + }, nil +} diff --git a/server/internal/codes/service_test.go b/server/internal/codes/service_test.go new file mode 100644 index 0000000..6329e8a --- /dev/null +++ b/server/internal/codes/service_test.go @@ -0,0 +1,586 @@ +//go:build integration + +package codes_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + "time" + + // MySQL driver registration. + _ "github.com/go-sql-driver/mysql" + "github.com/redis/go-redis/v9" + "github.com/testcontainers/testcontainers-go" + tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql" + tcredis "github.com/testcontainers/testcontainers-go/modules/redis" + + "pangolin/server/internal/codes" +) + +// -------------------------------------------------------------------------- +// Container setup helpers +// -------------------------------------------------------------------------- + +func setupMySQL(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + + ctr, err := tcmysql.Run(ctx, "mysql:8.0", + tcmysql.WithDatabase("pangolin_test"), + tcmysql.WithUsername("root"), + tcmysql.WithPassword("test"), + ) + testcontainers.CleanupContainer(t, ctr) + if err != nil { + t.Fatalf("mysql container: %v", err) + } + + dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'") + if err != nil { + t.Fatalf("mysql dsn: %v", err) + } + + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatalf("open mysql: %v", err) + } + t.Cleanup(func() { db.Close() }) + + if err := applySchema(db); err != nil { + t.Fatalf("schema: %v", err) + } + return db +} + +func setupRedis(t *testing.T) *redis.Client { + t.Helper() + ctx := context.Background() + + ctr, err := tcredis.Run(ctx, "redis:7-alpine") + testcontainers.CleanupContainer(t, ctr) + if err != nil { + t.Fatalf("redis container: %v", err) + } + + addr, err := ctr.ConnectionString(ctx) + if err != nil { + t.Fatalf("redis addr: %v", err) + } + // Remove the redis:// scheme prefix if present. + addr = stripScheme(addr) + + rdb := redis.NewClient(&redis.Options{Addr: addr}) + t.Cleanup(func() { rdb.Close() }) + return rdb +} + +func stripScheme(s string) string { + for _, prefix := range []string{"redis://", "rediss://"} { + if len(s) > len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):] + } + } + return s +} + +// applySchema runs the minimum DDL required by the codes package. +func applySchema(db *sql.DB) error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS plans ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code ENUM('free','pro','team') NOT NULL UNIQUE, + max_devices INT NOT NULL DEFAULT 1, + daily_minutes INT NULL, + ad_gate BOOLEAN NOT NULL DEFAULT FALSE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `CREATE TABLE IF NOT EXISTS code_batches ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + channel ENUM('store','tg','line','manual') NOT NULL, + created_by VARCHAR(64) NOT NULL, + note VARCHAR(255) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `CREATE TABLE IF NOT EXISTS codes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code_hash CHAR(64) NOT NULL UNIQUE, + plan_id BIGINT UNSIGNED NOT NULL, + duration_days INT NOT NULL, + batch_id BIGINT UNSIGNED NOT NULL, + status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused', + redeemed_by BIGINT UNSIGNED NULL, + redeemed_at DATETIME(6) NULL, + FOREIGN KEY (plan_id) REFERENCES plans(id), + FOREIGN KEY (batch_id) REFERENCES code_batches(id), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + expires_at DATETIME(6) NOT NULL, + source ENUM('trial','code') NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + FOREIGN KEY (plan_id) REFERENCES plans(id), + INDEX idx_user_exp (user_id, expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + target VARCHAR(128) NOT NULL, + meta JSON NULL, + at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_at (at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + // Seed plan rows. + `INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate) + VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`, + } + for _, stmt := range stmts { + if _, err := db.Exec(stmt); err != nil { + return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt) + } + } + return nil +} + +// insertCode inserts a test code directly into the DB and returns it. +func insertCode(t *testing.T, db *sql.DB, plaintext string, plan codes.PlanCode, durationDays int) { + t.Helper() + canonical, err := codes.Canonicalize(plaintext) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + hash := codes.Hash(canonical) + + var planID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, string(plan)).Scan(&planID); err != nil { + t.Fatalf("plan lookup: %v", err) + } + + _, err = db.Exec( + `INSERT INTO code_batches (channel, created_by, created_at) VALUES ('manual','test',UTC_TIMESTAMP(6))`, + ) + if err != nil { + t.Fatalf("batch insert: %v", err) + } + var batchID int64 + db.QueryRow(`SELECT LAST_INSERT_ID()`).Scan(&batchID) + + _, err = db.Exec( + `INSERT INTO codes (code_hash, plan_id, duration_days, batch_id) VALUES (?,?,?,?)`, + hash, planID, durationDays, batchID, + ) + if err != nil { + t.Fatalf("code insert: %v", err) + } +} + +// -------------------------------------------------------------------------- +// Integration tests +// -------------------------------------------------------------------------- + +// TestRedeemConcurrentSingleWinner verifies that when N goroutines try to +// redeem the same code simultaneously, exactly 1 succeeds and N-1 fail. +func TestRedeemConcurrentSingleWinner(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + const N = 20 + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanPro, 30) + + results := make([]bool, N) // true = success + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + go func(idx int) { + defer wg.Done() + userID := int64(1000 + idx) // different user for each goroutine + _, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: code, + }) + results[idx] = (apiErr == nil) + }(i) + } + wg.Wait() + + winners := 0 + for _, ok := range results { + if ok { + winners++ + } + } + if winners != 1 { + t.Errorf("expected exactly 1 winner, got %d", winners) + } +} + +// TestRedeemIdempotent verifies that the same user re-submitting the same +// code gets a 200 with Idempotent=true. +func TestRedeemIdempotent(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanPro, 30) + + const userID = int64(2001) + + // First redemption. + r1, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code}) + if apiErr != nil { + t.Fatalf("first redeem failed: %v", apiErr) + } + if r1.Idempotent { + t.Error("first redeem should not be idempotent") + } + + // Second redemption – same user, same code. + r2, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code}) + if apiErr2 != nil { + t.Fatalf("second redeem failed: %v", apiErr2) + } + if !r2.Idempotent { + t.Error("second redeem should be idempotent") + } +} + +// TestRedeemOtherUserFails verifies that a different user trying to redeem an +// already-redeemed code gets CODE_REDEEMED and that the failure is counted. +func TestRedeemOtherUserFails(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanPro, 30) + + // User A redeems. + _, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3001, Code: code}) + if apiErr != nil { + t.Fatalf("user A redeem: %v", apiErr) + } + + // User B tries the same code. + _, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3002, Code: code}) + if apiErr2 == nil { + t.Fatal("user B should have failed") + } + if apiErr2.Code != "CODE_REDEEMED" { + t.Errorf("expected CODE_REDEEMED, got %s", apiErr2.Code) + } +} + +// TestRedeemFailLock verifies that 5 consecutive failures lock the account for +// 1 hour and subsequent attempts return ACCOUNT_LOCKED. +func TestRedeemFailLock(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + // Use a very short lock for the test (we won't wait). + svc := codes.NewService(store, rdb, 5, time.Hour) + + const userID = int64(4001) + // Attempt 5 redemptions with invalid codes. + for i := 0; i < 5; i++ { + fakeCode, _ := codes.GenerateCode() // valid format but not in DB + _, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: fakeCode, + }) + if apiErr == nil { + t.Fatalf("attempt %d: expected failure", i+1) + } + if apiErr.Code == "ACCOUNT_LOCKED" { + t.Fatalf("locked too early at attempt %d", i+1) + } + } + + // 6th attempt should return ACCOUNT_LOCKED. + fakeCode, _ := codes.GenerateCode() + _, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: fakeCode, + }) + if apiErr == nil || apiErr.Code != "ACCOUNT_LOCKED" { + t.Errorf("expected ACCOUNT_LOCKED, got %v", apiErr) + } +} + +// TestSamePlanExtension verifies that redeeming a pro code when the user +// already has a pro subscription extends the existing subscription. +func TestSamePlanExtension(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + const userID = int64(5001) + + // Pre-insert a pro subscription expiring in 10 days. + var planID int64 + db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&planID) + initial := time.Now().UTC().AddDate(0, 0, 10) + db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,'trial')`, + userID, planID, initial, + ) + + // Redeem a 30-day pro code. + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanPro, 30) + + result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: code, + }) + if apiErr != nil { + t.Fatalf("redeem: %v", apiErr) + } + + // expires_at should be initial + 30 days (since initial > now). + wantExpiry := initial.AddDate(0, 0, 30) + diff := result.ExpiresAt.Sub(wantExpiry) + if diff < -2*time.Second || diff > 2*time.Second { + t.Errorf("expires_at = %v, want ≈%v (diff=%v)", result.ExpiresAt, wantExpiry, diff) + } +} + +// TestCrossPlanNewSubscription verifies that redeeming a team code when the +// user only has a pro subscription creates a new team subscription. +func TestCrossPlanNewSubscription(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + const userID = int64(6001) + + // Pre-insert a pro subscription. + var proID int64 + db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID) + db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,'2099-01-01','trial')`, + userID, proID, + ) + + // Redeem a 30-day team code. + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanTeam, 30) + + result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: code, + }) + if apiErr != nil { + t.Fatalf("redeem: %v", apiErr) + } + + // Should have created a new team subscription starting from now + 30 days. + wantMin := time.Now().UTC().AddDate(0, 0, 29) + wantMax := time.Now().UTC().AddDate(0, 0, 31) + if result.ExpiresAt.Before(wantMin) || result.ExpiresAt.After(wantMax) { + t.Errorf("team expires_at = %v, want within [%v, %v]", result.ExpiresAt, wantMin, wantMax) + } + + // Confirm the new row is a team subscription. + var count int + var teamID int64 + db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID) + db.QueryRow( + `SELECT COUNT(1) FROM subscriptions WHERE user_id=? AND plan_id=? AND source='code'`, + userID, teamID, + ).Scan(&count) + if count != 1 { + t.Errorf("expected 1 new team subscription, got %d", count) + } +} + +// TestAuditLogWritten verifies that every successful redemption produces an +// audit_log row. +func TestAuditLogWritten(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + const userID = int64(7001) + code, _ := codes.GenerateCode() + insertCode(t, db, code, codes.PlanPro, 30) + + _, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{ + UserID: userID, + Code: code, + }) + if apiErr != nil { + t.Fatalf("redeem: %v", apiErr) + } + + var count int + db.QueryRow( + `SELECT COUNT(1) FROM audit_log WHERE action='redeem' AND actor=?`, + fmt.Sprintf("user:%d", userID), + ).Scan(&count) + if count != 1 { + t.Errorf("expected 1 audit_log row, got %d", count) + } +} + +// TestCSVExportNoPlaintextInDB verifies that after a batch generation and +// CSV export, no plaintext code is stored in the database. +func TestCSVExportNoPlaintextInDB(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + svc := codes.NewService(store, rdb, 5, time.Hour) + + result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{ + PlanCode: codes.PlanPro, + DurationDays: 30, + Count: 10, + Channel: codes.ChannelManual, + Note: "integration test", + CreatedBy: "test", + }) + if err != nil { + t.Fatalf("CreateBatch: %v", err) + } + + if len(result.Codes) != 10 { + t.Fatalf("expected 10 codes, got %d", len(result.Codes)) + } + + // Write CSV. + var buf bytes.Buffer + rows := codes.BatchResultToCSVRows(result, time.Now()) + if err := codes.ExportCSV(&buf, rows); err != nil { + t.Fatalf("ExportCSV: %v", err) + } + + // Verify no plaintext appears in the codes table. + for _, c := range result.Codes { + var count int + // The table stores only code_hash; search for the plaintext as a string. + db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, c).Scan(&count) + if count > 0 { + t.Errorf("plaintext code %s found in codes.code_hash column!", c) + } + // Also verify the hash IS present. + h := codes.Hash(c) + db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, h).Scan(&count) + if count != 1 { + t.Errorf("hash for code %s not found in DB", c) + } + } +} + +// TestWebhookNonceReplay verifies that replaying a webhook nonce is rejected +// with status 200 (duplicate_ignored) and does NOT create a second DB row. +func TestWebhookNonceReplay(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + secret := "integration-secret" + + h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute) + + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + nonce := fmt.Sprintf("unique-nonce-%d", time.Now().UnixNano()) + + makeReq := func() *http.Request { + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + r.Header.Set("X-Pangolin-Nonce", nonce) + return r + } + + // First request: should succeed with 201. + w1 := httptest.NewRecorder() + h.ServeHTTP(w1, makeReq()) + if w1.Code != http.StatusCreated { + t.Errorf("first request: expected 201, got %d body=%s", w1.Code, w1.Body.String()) + } + + // Second request with same nonce: should return 200 duplicate_ignored. + w2 := httptest.NewRecorder() + h.ServeHTTP(w2, makeReq()) + if w2.Code != http.StatusOK { + t.Errorf("replay request: expected 200, got %d body=%s", w2.Code, w2.Body.String()) + } + + // Only one row should exist in codes. + hash := codes.Hash(code) + var count int + db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count) + if count != 1 { + t.Errorf("expected 1 code row, got %d", count) + } +} + +// TestWebhookSameHashIdempotent verifies that pushing the same code_hash twice +// (different nonce) is idempotent: no duplicate row is created. +func TestWebhookSameHashIdempotent(t *testing.T) { + db := setupMySQL(t) + rdb := setupRedis(t) + store := codes.NewStore(db) + secret := "integration-secret-2" + + h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute) + + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + sendReq := func(nonce string) int { + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + r.Header.Set("X-Pangolin-Nonce", nonce) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w.Code + } + + status1 := sendReq(fmt.Sprintf("nonce-a-%d", time.Now().UnixNano())) + if status1 != http.StatusCreated { + t.Fatalf("first request: expected 201, got %d", status1) + } + + status2 := sendReq(fmt.Sprintf("nonce-b-%d", time.Now().UnixNano())) + if status2 != http.StatusOK { + t.Fatalf("second (same hash) request: expected 200, got %d", status2) + } + + // Still only one row. + hash := codes.Hash(code) + var count int + db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count) + if count != 1 { + t.Errorf("expected 1 code row after idempotent push, got %d", count) + } +} diff --git a/server/internal/codes/store.go b/server/internal/codes/store.go new file mode 100644 index 0000000..e4f24c3 --- /dev/null +++ b/server/internal/codes/store.go @@ -0,0 +1,310 @@ +package codes + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// PlanCode represents a plan tier. +type PlanCode string + +const ( + PlanFree PlanCode = "free" + PlanPro PlanCode = "pro" + PlanTeam PlanCode = "team" +) + +// planTier returns a numeric tier for comparison (higher = better). +func planTier(p PlanCode) int { + switch p { + case PlanTeam: + return 3 + case PlanPro: + return 2 + default: + return 1 + } +} + +// BatchChannel is the source channel for a code batch. +type BatchChannel string + +const ( + ChannelStore BatchChannel = "store" + ChannelTG BatchChannel = "tg" + ChannelLine BatchChannel = "line" + ChannelManual BatchChannel = "manual" +) + +// CodeRow mirrors the `codes` DB row (hash only; no plaintext). +type CodeRow struct { + ID int64 + CodeHash string // SHA-256 hex of canonical plaintext + PlanID int64 + PlanCode PlanCode + DurationDays int + BatchID int64 + Status string // unused | redeemed | void + RedeemedBy sql.NullInt64 + RedeemedAt sql.NullTime +} + +// SubscriptionRow mirrors the `subscriptions` DB row. +type SubscriptionRow struct { + ID int64 + UserID int64 + PlanID int64 + PlanCode PlanCode + ExpiresAt time.Time + Source string +} + +// BatchRow mirrors the `code_batches` DB row. +type BatchRow struct { + ID int64 + Channel BatchChannel + CreatedBy string + Note sql.NullString + CreatedAt time.Time +} + +// Store wraps a *sql.DB and exposes all database operations needed by the +// codes package. Every method that modifies data is safe to call inside or +// outside an explicit transaction; methods that accept a *sql.Tx run within +// that transaction. +type Store struct { + db *sql.DB +} + +// NewStore creates a Store backed by the given MySQL connection pool. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// -------------------------------------------------------------------------- +// Batch and code creation (used by Service.CreateBatch and webhook) +// -------------------------------------------------------------------------- + +// CreateBatch inserts a new code_batches row and returns its ID. +func (s *Store) CreateBatch(ctx context.Context, channel BatchChannel, createdBy, note string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `INSERT INTO code_batches (channel, created_by, note, created_at) + VALUES (?, ?, NULLIF(?, ''), UTC_TIMESTAMP(6))`, + string(channel), createdBy, note) + if err != nil { + return 0, fmt.Errorf("store.CreateBatch: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("store.CreateBatch last id: %w", err) + } + return id, nil +} + +// CreateCode inserts a single codes row. It returns ErrDuplicate if a row +// with the same code_hash already exists (UNIQUE constraint violation). +func (s *Store) CreateCode(ctx context.Context, codeHash string, planID int64, durationDays int, batchID int64) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO codes (code_hash, plan_id, duration_days, batch_id, status) + VALUES (?, ?, ?, ?, 'unused')`, + codeHash, planID, durationDays, batchID) + if err != nil { + if isDuplicateKey(err) { + return ErrDuplicate + } + return fmt.Errorf("store.CreateCode: %w", err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Redemption (used by Service.Redeem inside a transaction) +// -------------------------------------------------------------------------- + +// FindCodeByHashForUpdate looks up a codes row by its SHA-256 hash using +// SELECT … FOR UPDATE so that the row is locked for the duration of the +// surrounding transaction. It also fetches the plan code from the plans +// table in the same query. +func (s *Store) FindCodeByHashForUpdate(ctx context.Context, tx *sql.Tx, hash string) (*CodeRow, error) { + row := tx.QueryRowContext(ctx, + `SELECT c.id, c.code_hash, c.plan_id, p.code, c.duration_days, + c.batch_id, c.status, c.redeemed_by, c.redeemed_at + FROM codes c + JOIN plans p ON p.id = c.plan_id + WHERE c.code_hash = ? + FOR UPDATE`, + hash) + + var cr CodeRow + if err := row.Scan( + &cr.ID, &cr.CodeHash, &cr.PlanID, &cr.PlanCode, &cr.DurationDays, + &cr.BatchID, &cr.Status, &cr.RedeemedBy, &cr.RedeemedAt, + ); err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("store.FindCodeByHashForUpdate: %w", err) + } + return &cr, nil +} + +// MarkRedeemed updates a codes row to status='redeemed' within tx. +func (s *Store) MarkRedeemed(ctx context.Context, tx *sql.Tx, codeID, userID int64) error { + _, err := tx.ExecContext(ctx, + `UPDATE codes SET status='redeemed', redeemed_by=?, redeemed_at=UTC_TIMESTAMP(6) + WHERE id=? AND status='unused'`, + userID, codeID) + if err != nil { + return fmt.Errorf("store.MarkRedeemed: %w", err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Subscription helpers (used inside redeem transaction) +// -------------------------------------------------------------------------- + +// GetPlanID returns the primary key of plans.code. +func (s *Store) GetPlanID(ctx context.Context, code PlanCode) (int64, error) { + var id int64 + err := s.db.QueryRowContext(ctx, `SELECT id FROM plans WHERE code=?`, string(code)).Scan(&id) + if err != nil { + return 0, fmt.Errorf("store.GetPlanID(%s): %w", code, err) + } + return id, nil +} + +// GetActiveSubscriptions returns all non-expired subscriptions for userID, +// ordered by expires_at DESC. Called inside the redeem transaction. +func (s *Store) GetActiveSubscriptions(ctx context.Context, tx *sql.Tx, userID int64) ([]SubscriptionRow, error) { + rows, err := tx.QueryContext(ctx, + `SELECT s.id, s.user_id, s.plan_id, p.code, s.expires_at, s.source + FROM subscriptions s + JOIN plans p ON p.id = s.plan_id + WHERE s.user_id=? AND s.expires_at > UTC_TIMESTAMP(6) + ORDER BY s.expires_at DESC`, + userID) + if err != nil { + return nil, fmt.Errorf("store.GetActiveSubscriptions: %w", err) + } + defer rows.Close() + + var subs []SubscriptionRow + for rows.Next() { + var sr SubscriptionRow + if err := rows.Scan(&sr.ID, &sr.UserID, &sr.PlanID, &sr.PlanCode, &sr.ExpiresAt, &sr.Source); err != nil { + return nil, fmt.Errorf("store.GetActiveSubscriptions scan: %w", err) + } + subs = append(subs, sr) + } + return subs, rows.Err() +} + +// ExtendSubscription sets expires_at to max(current_expires_at, now) + duration. +// Called inside the redeem transaction. +func (s *Store) ExtendSubscription(ctx context.Context, tx *sql.Tx, subID int64, durationDays int) error { + _, err := tx.ExecContext(ctx, + `UPDATE subscriptions + SET expires_at = DATE_ADD( + GREATEST(expires_at, UTC_TIMESTAMP(6)), + INTERVAL ? DAY) + WHERE id=?`, + durationDays, subID) + if err != nil { + return fmt.Errorf("store.ExtendSubscription: %w", err) + } + return nil +} + +// CreateSubscription inserts a new subscriptions row. expires_at is set to +// max(now, latest_expires_at_for_same_plan) + duration_days. +// latestSamePlan may be zero-value if the user has no existing sub for that plan. +func (s *Store) CreateSubscription( + ctx context.Context, + tx *sql.Tx, + userID, planID int64, + durationDays int, + latestSamePlan time.Time, +) (int64, error) { + now := time.Now().UTC() + base := now + if latestSamePlan.After(now) { + base = latestSamePlan + } + expiresAt := base.AddDate(0, 0, durationDays) + + res, err := tx.ExecContext(ctx, + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source, created_at) + VALUES (?, ?, ?, 'code', UTC_TIMESTAMP(6))`, + userID, planID, expiresAt) + if err != nil { + return 0, fmt.Errorf("store.CreateSubscription: %w", err) + } + id, _ := res.LastInsertId() + return id, nil +} + +// -------------------------------------------------------------------------- +// Audit log +// -------------------------------------------------------------------------- + +// WriteAuditLog inserts an audit_log row. actor and target are string +// identifiers; meta is a JSON object (may be nil/empty). +func (s *Store) WriteAuditLog(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error { + var err error + if metaJSON == "" { + metaJSON = "null" + } + if tx != nil { + _, err = tx.ExecContext(ctx, + `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`, + actor, action, target, metaJSON) + } else { + _, err = s.db.ExecContext(ctx, + `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`, + actor, action, target, metaJSON) + } + if err != nil { + return fmt.Errorf("store.WriteAuditLog: %w", err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Webhook-specific lookup +// -------------------------------------------------------------------------- + +// CodeExistsByHash returns true if a codes row with the given hash already exists. +func (s *Store) CodeExistsByHash(ctx context.Context, hash string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count) + if err != nil { + return false, fmt.Errorf("store.CodeExistsByHash: %w", err) + } + return count > 0, nil +} + +// BeginTx starts a new transaction at Read Committed isolation level. +// The SELECT … FOR UPDATE in FindCodeByHashForUpdate provides the necessary +// row-level exclusivity; Serializable is intentionally avoided to reduce +// deadlock risk under concurrent redemptions. +func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { + return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +// -------------------------------------------------------------------------- +// helper +// -------------------------------------------------------------------------- + +// isDuplicateKey returns true if err is a MySQL duplicate-key error (1062). +func isDuplicateKey(err error) bool { + if err == nil { + return false + } + // go-sql-driver wraps MySQL errors; the error number is accessible via + // the mysql.MySQLError type. We do a string match to avoid importing + // the mysql package here. + msg := err.Error() + return strings.Contains(msg, "Duplicate entry") || + strings.Contains(msg, "1062") +} diff --git a/server/internal/codes/webhook.go b/server/internal/codes/webhook.go new file mode 100644 index 0000000..0555245 --- /dev/null +++ b/server/internal/codes/webhook.go @@ -0,0 +1,237 @@ +package codes + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/redis/go-redis/v9" + "pangolin/server/internal/apierr" +) + +// WebhookHandler handles POST /webhook/store/codes requests from the +// card store (发卡店). It is mounted outside /v1 and requires no JWT. +// +// Security model: +// - HMAC-SHA256 signature in X-Pangolin-Signature: sha256= +// - Unix timestamp in X-Pangolin-Timestamp (±5 min window) +// - Unique nonce in X-Pangolin-Nonce to prevent replay attacks +// - Nonce is stored in Redis with a TTL > 2× the timestamp window +type WebhookHandler struct { + store *Store + rdb *redis.Client + secret []byte + timestampTolerance time.Duration + nonceTTL time.Duration +} + +// NewWebhookHandler creates a WebhookHandler. +// secret is the raw HMAC key (not hex-encoded). +func NewWebhookHandler( + store *Store, + rdb *redis.Client, + secret string, + timestampTolerance time.Duration, + nonceTTL time.Duration, +) *WebhookHandler { + return &WebhookHandler{ + store: store, + rdb: rdb, + secret: []byte(secret), + timestampTolerance: timestampTolerance, + nonceTTL: nonceTTL, + } +} + +// WebhookPayload is the JSON body sent by the card store. +type WebhookPayload struct { + // Code is the plaintext activation code generated by the card store. + Code string `json:"code"` + // Plan is the plan tier (free|pro|team). + Plan string `json:"plan"` + // DurationDays is the number of days this code grants. + DurationDays int `json:"duration_days"` + // Note is an optional human-readable remark. + Note string `json:"note,omitempty"` +} + +// redisKeyNonce returns the Redis key for a webhook nonce. +func redisKeyNonce(nonce string) string { + return "webhook:nonce:" + nonce +} + +// ServeHTTP implements http.Handler for POST /webhook/store/codes. +func (h *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read body (limit to 64 KB to prevent DoS). + body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + if err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + // --- Signature verification --- + if apiErr := h.verifySignature(r, body); apiErr != nil { + apierr.WriteJSON(w, http.StatusUnauthorized, apiErr) + return + } + + // --- Timestamp window --- + if apiErr := h.verifyTimestamp(r); apiErr != nil { + apierr.WriteJSON(w, http.StatusUnauthorized, apiErr) + return + } + + // --- Nonce deduplication --- + nonce := r.Header.Get("X-Pangolin-Nonce") + if nonce == "" { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + isDupe, apiErr := h.checkAndStoreNonce(r.Context(), nonce) + if apiErr != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if isDupe { + // Idempotent: return 200 to acknowledge without re-inserting. + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "duplicate_ignored"}) + return + } + + // --- Parse payload --- + var payload WebhookPayload + if err := json.Unmarshal(body, &payload); err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + if payload.Code == "" || payload.Plan == "" || payload.DurationDays <= 0 { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + // --- Canonicalize and hash the code --- + canonical, cerr := Canonicalize(payload.Code) + if cerr != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrInvalidCode) + return + } + hash := Hash(canonical) + + // --- Idempotent code insertion --- + ctx := r.Context() + + planID, err := h.store.GetPlanID(ctx, PlanCode(strings.ToLower(payload.Plan))) + if err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + + // Create batch (one per webhook call; the store may aggregate externally). + batchID, err := h.store.CreateBatch(ctx, ChannelStore, "webhook", payload.Note) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + err = h.store.CreateCode(ctx, hash, planID, payload.DurationDays, batchID) + if err == ErrDuplicate { + // Same code_hash already exists – idempotent, do not error. + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "already_exists"}) + return + } + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "created"}) +} + +// verifySignature checks the X-Pangolin-Signature header. +// Expected format: "sha256=" +// HMAC is computed over the raw request body. +func (h *WebhookHandler) verifySignature(r *http.Request, body []byte) *apierr.Error { + sig := r.Header.Get("X-Pangolin-Signature") + if sig == "" { + return apierr.ErrWebhookSignature + } + if !strings.HasPrefix(sig, "sha256=") { + return apierr.ErrWebhookSignature + } + gotHex := strings.TrimPrefix(sig, "sha256=") + gotBytes, err := hex.DecodeString(gotHex) + if err != nil { + return apierr.ErrWebhookSignature + } + + mac := hmac.New(sha256.New, h.secret) + mac.Write(body) + expected := mac.Sum(nil) + + // Constant-time comparison to prevent timing attacks. + if !hmac.Equal(gotBytes, expected) { + return apierr.ErrWebhookSignature + } + return nil +} + +// verifyTimestamp checks the X-Pangolin-Timestamp header. +// The timestamp must be within ± h.timestampTolerance of the current time. +func (h *WebhookHandler) verifyTimestamp(r *http.Request) *apierr.Error { + tsStr := r.Header.Get("X-Pangolin-Timestamp") + if tsStr == "" { + return apierr.ErrWebhookTimestamp + } + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil { + return apierr.ErrWebhookTimestamp + } + t := time.Unix(ts, 0) + now := time.Now().UTC() + diff := now.Sub(t) + if diff < 0 { + diff = -diff + } + if diff > h.timestampTolerance { + return apierr.ErrWebhookTimestamp + } + return nil +} + +// checkAndStoreNonce checks whether nonce has been seen before. +// If not seen, it stores it in Redis with the nonce TTL and returns false. +// If already seen, it returns true (duplicate). +// Uses SET NX to make the check-and-store atomic. +// Returns an error if rdb is nil (misconfiguration). +func (h *WebhookHandler) checkAndStoreNonce(ctx context.Context, nonce string) (bool, error) { + if h.rdb == nil { + return false, fmt.Errorf("webhook: Redis client is not configured") + } + key := redisKeyNonce(nonce) + // SET key "1" NX EX + set, err := h.rdb.SetNX(ctx, key, "1", h.nonceTTL).Result() + if err != nil { + return false, fmt.Errorf("webhook checkNonce: %w", err) + } + // SetNX returns true if the key was set (not a duplicate). + return !set, nil +} diff --git a/server/internal/codes/webhook_test.go b/server/internal/codes/webhook_test.go new file mode 100644 index 0000000..1041a69 --- /dev/null +++ b/server/internal/codes/webhook_test.go @@ -0,0 +1,198 @@ +package codes_test + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "pangolin/server/internal/codes" +) + +// --- HMAC helper used across tests --- + +func signBody(secret string, body []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// newWebhookHandlerNoRedis creates a WebhookHandler with a nil Redis client. +// Safe only for tests where the handler returns before the nonce check. +func newWebhookHandlerNoRedis(secret string) *codes.WebhookHandler { + return codes.NewWebhookHandler(nil, nil, secret, 5*time.Minute, 15*time.Minute) +} + +// --- Signature validation tests (no Redis required) --- + +// TestWebhookSignatureRejected verifies that a bad HMAC causes a 401. +// The signature check fires before any Redis or DB access. +func TestWebhookSignatureRejected(t *testing.T) { + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + // Wrong secret. + r.Header.Set("X-Pangolin-Signature", signBody("wrong-secret", body)) + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + r.Header.Set("X-Pangolin-Nonce", "test-nonce-1") + + h := newWebhookHandlerNoRedis("correct-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String()) + } + assertErrorCode(t, w, "WEBHOOK_INVALID_SIGNATURE") +} + +// TestWebhookMissingSignatureHeader verifies that a missing signature causes a 401. +func TestWebhookMissingSignatureHeader(t *testing.T) { + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + // No X-Pangolin-Signature. + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + r.Header.Set("X-Pangolin-Nonce", "test-nonce-nosig") + + h := newWebhookHandlerNoRedis("any-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +// TestWebhookTimestampRejected verifies that a stale timestamp causes a 401. +// The timestamp check fires after signature but before Redis. +func TestWebhookTimestampRejected(t *testing.T) { + secret := "test-secret" + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) + // Timestamp is 10 minutes in the past – outside ±5 min window. + staleTs := time.Now().Add(-10 * time.Minute).Unix() + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(staleTs, 10)) + r.Header.Set("X-Pangolin-Nonce", "test-nonce-stale") + + h := newWebhookHandlerNoRedis(secret) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String()) + } + assertErrorCode(t, w, "WEBHOOK_TIMESTAMP_EXPIRED") +} + +// TestWebhookFutureTimestampRejected verifies that a far-future timestamp is also rejected. +func TestWebhookFutureTimestampRejected(t *testing.T) { + secret := "test-secret" + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) + futureTs := time.Now().Add(10 * time.Minute).Unix() + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(futureTs, 10)) + r.Header.Set("X-Pangolin-Nonce", "test-nonce-future") + + h := newWebhookHandlerNoRedis(secret) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +// TestWebhookMissingNonce verifies that a missing nonce causes a 400. +// Missing nonce is checked before Redis access (nil nonce → BadRequest). +func TestWebhookMissingNonce(t *testing.T) { + secret := "test-secret" + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + // No X-Pangolin-Nonce header. + + h := newWebhookHandlerNoRedis(secret) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d; body=%s", w.Code, w.Body.String()) + } +} + +// TestWebhookHMACConstantTime verifies that a correct-length HMAC with wrong +// bytes is still rejected – i.e., hmac.Equal (constant-time) is used, not ==. +func TestWebhookHMACConstantTime(t *testing.T) { + secret := "test-secret" + code, _ := codes.GenerateCode() + payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} + body, _ := json.Marshal(payload) + + // Produce a MAC of the correct length but with the last byte flipped. + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + correctMAC := mac.Sum(nil) + correctMAC[len(correctMAC)-1] ^= 0xFF + wrongSig := "sha256=" + hex.EncodeToString(correctMAC) + + r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set("X-Pangolin-Signature", wrongSig) + r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + r.Header.Set("X-Pangolin-Nonce", "test-nonce-ct") + + h := newWebhookHandlerNoRedis(secret) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +// -------------------------------------------------------------------------- +// Helper +// -------------------------------------------------------------------------- + +// assertErrorCode decodes the response body and checks the "code" field. +func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) { + t.Helper() + var body struct { + Code string `json:"code"` + } + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Errorf("decode error body: %v", err) + return + } + if body.Code != wantCode { + t.Errorf("error code = %q, want %q", body.Code, wantCode) + } +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..fcfd5e5 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,71 @@ +// Package config holds application configuration loaded from environment variables. +package config + +import ( + "fmt" + "os" + "time" +) + +// Config holds all application-level configuration. +type Config struct { + // DSN is the MySQL connection string. + // Format: user:password@tcp(host:port)/dbname?parseTime=true&loc=UTC&time_zone=%%27UTC%%27 + DSN string + + // RedisAddr is the Redis server address (host:port). + RedisAddr string + RedisPassword string + RedisDB int + + // WebhookSecret is the HMAC-SHA256 shared secret for the card-store webhook. + // Must be set; no default. + WebhookSecret string + + // RedeemFailMax is the number of consecutive redeem failures before a 1-hour lock. + // Default: 5 + RedeemFailMax int + + // RedeemLockDuration is how long the lock lasts after hitting RedeemFailMax. + // Default: 1 hour + RedeemLockDuration time.Duration + + // WebhookTimestampTolerance is the ±window for webhook timestamp validation. + // Default: 5 minutes + WebhookTimestampTolerance time.Duration + + // WebhookNonceTTL is how long a webhook nonce is kept in Redis to prevent replay. + // Should be > 2 * WebhookTimestampTolerance. Default: 15 minutes. + WebhookNonceTTL time.Duration +} + +// FromEnv reads configuration from environment variables. +// Returns an error if required variables are missing. +func FromEnv() (*Config, error) { + c := &Config{ + DSN: os.Getenv("DB_DSN"), + RedisAddr: getEnvDefault("REDIS_ADDR", "127.0.0.1:6379"), + RedisPassword: os.Getenv("REDIS_PASSWORD"), + RedisDB: 0, + WebhookSecret: os.Getenv("WEBHOOK_SECRET"), + RedeemFailMax: 5, + RedeemLockDuration: time.Hour, + WebhookTimestampTolerance: 5 * time.Minute, + WebhookNonceTTL: 15 * time.Minute, + } + + if c.DSN == "" { + return nil, fmt.Errorf("config: DB_DSN is required") + } + if c.WebhookSecret == "" { + return nil, fmt.Errorf("config: WEBHOOK_SECRET is required") + } + return c, nil +} + +func getEnvDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/server/internal/db/db.go b/server/internal/db/db.go new file mode 100644 index 0000000..868cea9 --- /dev/null +++ b/server/internal/db/db.go @@ -0,0 +1,29 @@ +// Package db provides a MySQL connection helper. +package db + +import ( + "database/sql" + "fmt" + "time" + + // Register mysql driver. + _ "github.com/go-sql-driver/mysql" +) + +// Open opens a *sql.DB backed by MySQL and pings the server. +// The DSN must include parseTime=true and time_zone='+00:00' (UTC). +func Open(dsn string) (*sql.DB, error) { + db, err := sql.Open("mysql", dsn) + if err != nil { + return nil, fmt.Errorf("db.Open: %w", err) + } + db.SetMaxOpenConns(30) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("db.Open ping: %w", err) + } + return db, nil +} diff --git a/server/internal/redisutil/redis.go b/server/internal/redisutil/redis.go new file mode 100644 index 0000000..dda0d19 --- /dev/null +++ b/server/internal/redisutil/redis.go @@ -0,0 +1,22 @@ +// Package redisutil provides a Redis client constructor. +package redisutil + +import ( + "context" + "fmt" + + "github.com/redis/go-redis/v9" +) + +// New creates and pings a Redis client. +func New(addr, password string, db int) (*redis.Client, error) { + rdb := redis.NewClient(&redis.Options{ + Addr: addr, + Password: password, + DB: db, + }) + if err := rdb.Ping(context.Background()).Err(); err != nil { + return nil, fmt.Errorf("redisutil.New ping: %w", err) + } + return rdb, nil +} diff --git a/server/migrations/001_init.sql b/server/migrations/001_init.sql new file mode 100644 index 0000000..524060c --- /dev/null +++ b/server/migrations/001_init.sql @@ -0,0 +1,94 @@ +-- Migration 001: baseline schema for the codes module and its dependencies. +-- MySQL 8.x · InnoDB · utf8mb4 · UTC +-- Apply with: mysql -u root -p pangolin < migrations/001_init.sql + +SET time_zone = '+00:00'; + +-- ------------------------------------------------------------------------- +-- Core account tables (needed by foreign keys from subscriptions) +-- ------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL UNIQUE, -- public-facing identifier + email VARCHAR(255) NOT NULL UNIQUE, + pw_hash VARCHAR(255) NOT NULL, -- argon2id + dp_uuid CHAR(36) NOT NULL, -- data-plane UUID (sing-box) + status ENUM('active','banned') NOT NULL DEFAULT 'active', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------------------- +-- Plan catalogue (digital values per design/CLAUDE.md §7) +-- ------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS plans ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code ENUM('free','pro','team') NOT NULL UNIQUE, + max_devices INT NOT NULL, -- free 1 / pro 5 / team 10 + daily_minutes INT NULL, -- free 10; NULL = unlimited + ad_gate BOOLEAN NOT NULL DEFAULT FALSE -- free users must watch an ad daily +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate) VALUES + ('free', 1, 10, TRUE), + ('pro', 5, NULL, FALSE), + ('team', 10, NULL, FALSE); + +-- ------------------------------------------------------------------------- +-- Subscriptions +-- ------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS subscriptions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + plan_id BIGINT UNSIGNED NOT NULL, + expires_at DATETIME(6) NOT NULL, + source ENUM('trial','code') NOT NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (plan_id) REFERENCES plans(id), + INDEX idx_user_exp (user_id, expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------------------- +-- Activation-code tables +-- ------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS code_batches ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + channel ENUM('store','tg','line','manual') NOT NULL, + created_by VARCHAR(64) NOT NULL, + note VARCHAR(255) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS codes ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + -- SHA-256(canonical_plaintext) stored as 64-char hex. + -- Plaintext NEVER stored in any column. + code_hash CHAR(64) NOT NULL UNIQUE, + plan_id BIGINT UNSIGNED NOT NULL, + duration_days INT NOT NULL, + batch_id BIGINT UNSIGNED NOT NULL, + status ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused', + redeemed_by BIGINT UNSIGNED NULL, -- FK to users.id (not enforced to avoid + redeemed_at DATETIME(6) NULL, -- locking the users table on redeem) + FOREIGN KEY (plan_id) REFERENCES plans(id), + FOREIGN KEY (batch_id) REFERENCES code_batches(id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------------------- +-- Audit log (all sensitive operations must be recorded) +-- ------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + actor VARCHAR(64) NOT NULL, -- e.g. "user:123" or "admin:7" + action VARCHAR(64) NOT NULL, -- e.g. "redeem", "ban", "node_drain" + target VARCHAR(128) NOT NULL, -- abbreviated; never contains plaintext codes + meta JSON NULL, -- structured context (no PII beyond IDs) + at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + INDEX idx_at (at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;