feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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=<hex>
|
||||||
|
// - 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=<lowercase-hex-hmac>"
|
||||||
|
// 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 <ttl seconds>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user