diff --git a/design/server/openapi.yaml b/design/server/openapi.yaml index f1785c4..df56034 100644 --- a/design/server/openapi.yaml +++ b/design/server/openapi.yaml @@ -83,7 +83,7 @@ components: description: null 表示无限期(免费版) source: type: string - enum: [trial, code] + enum: [trial, code, admin, free] Device: type: object diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 9ff7fa7..5fe8e3e 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -702,8 +702,8 @@ components: description: 订阅到期时间(UTC ISO-8601),免费版无到期时间时为 null source: type: string - enum: [trial, code, admin] - description: 订阅来源(试用 / 激活码 / 管理员) + enum: [trial, code, admin, free] + description: 订阅来源(试用 / 激活码 / 管理员 / 无订阅时的免费回落) TodayUsageSummary: type: object diff --git a/server/internal/apierr/apierr.go b/server/internal/apierr/apierr.go index 2d597d4..8bd6309 100644 --- a/server/internal/apierr/apierr.go +++ b/server/internal/apierr/apierr.go @@ -70,6 +70,11 @@ var ( MessageZH: "服务器内部错误,请稍后重试", MessageEn: "Internal server error, please try again later", } + ErrAccountBanned = &Error{ + Code: "ACCOUNT_BANNED", + MessageZH: "账户已被封禁,无法继续操作", + MessageEn: "This account has been banned", + } ) // Activation-code errors. @@ -137,6 +142,8 @@ func StatusFor(e *Error) int { return http.StatusNotFound case "CONFLICT": return http.StatusConflict + case "ACCOUNT_BANNED": + return http.StatusForbidden case "RATE_LIMITED", "ACCOUNT_LOCKED": return http.StatusTooManyRequests case "INTERNAL_ERROR": diff --git a/server/internal/devices/context.go b/server/internal/devices/context.go new file mode 100644 index 0000000..7754bec --- /dev/null +++ b/server/internal/devices/context.go @@ -0,0 +1,80 @@ +package devices + +import ( + "context" + "time" +) + +// ctxKey is a private type for context keys to avoid collisions. +type ctxKey string + +// CtxKeyUserID is the context key under which the authenticated user's +// internal int64 ID is stored by the JWT auth middleware (module #2). +// +// It mirrors the key used by the codes module so that, once the auth +// middleware lands, a single canonical key can be reconciled across modules. +const CtxKeyUserID ctxKey = "user_id" + +// ctxKeyPlan is the context key under which the resolved subscription Plan is +// stored by SubscriptionMiddleware. +const ctxKeyPlan ctxKey = "plan" + +// WithUserID returns a copy of ctx carrying the authenticated user ID. +// Primarily used in tests and by the auth middleware. +func WithUserID(ctx context.Context, userID int64) context.Context { + return context.WithValue(ctx, CtxKeyUserID, userID) +} + +// UserIDFromContext extracts the authenticated user ID from ctx. +// ok is false when no (valid) user ID is present. +func UserIDFromContext(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(CtxKeyUserID).(int64) + if !ok || v == 0 { + return 0, false + } + return v, true +} + +// Plan is the resolved effective subscription for a user, injected into the +// request context by SubscriptionMiddleware and consumed by nodes (catalogue +// filtering, connect), usage (daily caps), and the /v1/me summary. +type Plan struct { + // PlanCode is one of "free", "pro", "team". + PlanCode string + // ExpiresAt is the effective subscription expiry (UTC); nil for the free + // fallback when the user has no active paid/trial subscription. + ExpiresAt *time.Time + // MaxDevices is the per-plan device cap (free 1 / pro 5 / team 10). + MaxDevices int + // DailyMinutes is the free-plan daily time cap in minutes; nil = unlimited. + DailyMinutes *int + // AdGate is true when the user must watch a rewarded ad to unlock daily use. + AdGate bool + // Source is the subscription source: "trial", "code", "admin", or "free" + // for the no-subscription fallback. + Source string +} + +// WithPlan returns a copy of ctx carrying the resolved Plan. +func WithPlan(ctx context.Context, p Plan) context.Context { + return context.WithValue(ctx, ctxKeyPlan, p) +} + +// PlanFromCtx extracts the resolved Plan from ctx. ok is false when +// SubscriptionMiddleware has not run. +func PlanFromCtx(ctx context.Context) (Plan, bool) { + p, ok := ctx.Value(ctxKeyPlan).(Plan) + return p, ok +} + +// planTier maps a plan code to a numeric tier (higher = better) for comparison. +func planTier(code string) int { + switch code { + case "team": + return 3 + case "pro": + return 2 + default: // "free" or unknown + return 1 + } +} diff --git a/server/internal/devices/devices_integration_test.go b/server/internal/devices/devices_integration_test.go new file mode 100644 index 0000000..23f2083 --- /dev/null +++ b/server/internal/devices/devices_integration_test.go @@ -0,0 +1,362 @@ +//go:build integration + +package devices_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + _ "github.com/go-sql-driver/mysql" + "github.com/testcontainers/testcontainers-go" + tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql" + + "github.com/wangjia/pangolin/server/internal/devices" +) + +// -------------------------------------------------------------------------- +// Container + schema setup +// -------------------------------------------------------------------------- + +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 applySchema(db *sql.DB) error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL UNIQUE, + pw_hash VARCHAR(255) NOT NULL DEFAULT '', + dp_uuid CHAR(36) NOT NULL, + status ENUM('active','banned') NOT NULL DEFAULT 'active', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `CREATE TABLE IF NOT EXISTS devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + uuid CHAR(36) NOT NULL UNIQUE, + user_id BIGINT UNSIGNED NOT NULL, + name VARCHAR(64) NOT NULL, + platform ENUM('ios','android','windows','macos') NOT NULL, + last_seen DATETIME(6) NULL, + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + FOREIGN KEY (user_id) REFERENCES users(id), + INDEX idx_user (user_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, + + `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, + daily_minutes INT NULL, + ad_gate BOOLEAN NOT NULL DEFAULT FALSE + ) 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 (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`, + + `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`, + + `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 +} + +// createUser inserts a user (status active) and returns its id. +func createUser(t *testing.T, db *sql.DB, email, status string) int64 { + t.Helper() + res, err := db.Exec( + `INSERT INTO users (uuid, email, pw_hash, dp_uuid, status) + VALUES (UUID(), ?, 'x', UUID(), ?)`, email, status) + if err != nil { + t.Fatalf("createUser: %v", err) + } + id, _ := res.LastInsertId() + return id +} + +// giveSubscription inserts a subscription for the user. +func giveSubscription(t *testing.T, db *sql.DB, userID int64, plan, source string, expiresAt time.Time) { + t.Helper() + var planID int64 + if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, plan).Scan(&planID); err != nil { + t.Fatalf("plan lookup: %v", err) + } + if _, err := db.Exec( + `INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,?)`, + userID, planID, expiresAt.UTC(), source); err != nil { + t.Fatalf("giveSubscription: %v", err) + } +} + +func newUUID(t *testing.T, db *sql.DB) string { + t.Helper() + var u string + if err := db.QueryRow(`SELECT UUID()`).Scan(&u); err != nil { + t.Fatalf("uuid: %v", err) + } + return u +} + +// -------------------------------------------------------------------------- +// Tests +// -------------------------------------------------------------------------- + +// TestFullChain exercises register → implicit re-register → list → delete and +// asserts credential revocation is triggered on delete. +func TestFullChain(t *testing.T) { + db := setupMySQL(t) + store := devices.NewStore(db) + revoker := &devices.NoopRevoker{} + svc := devices.NewService(store, revoker) + ctx := context.Background() + + userID := createUser(t, db, "chain@example.com", "active") + // 7-day pro trial → effective plan pro (max 5). + giveSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(7*24*time.Hour)) + + plan, apiErr := svc.ResolvePlan(ctx, userID) + if apiErr != nil { + t.Fatalf("ResolvePlan: %v", apiErr) + } + if plan.PlanCode != "pro" || plan.MaxDevices != 5 { + t.Fatalf("want pro/5, got %s/%d", plan.PlanCode, plan.MaxDevices) + } + + devUUID := newUUID(t, db) + in := devices.RegisterInput{UserID: userID, DeviceUUID: devUUID, Name: "iPhone 15 Pro", Platform: "ios", MaxDevices: plan.MaxDevices} + + // First sight → insert. + d1, apiErr := svc.RegisterIfAbsent(ctx, in) + if apiErr != nil { + t.Fatalf("RegisterIfAbsent: %v", apiErr) + } + if d1.UUID != devUUID || d1.LastSeen == nil { + t.Fatalf("unexpected device: %+v", d1) + } + + // Second sight → idempotent (no new row), last_seen refreshed. + if _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil { + t.Fatalf("re-register: %v", apiErr) + } + list, apiErr := svc.ListDevices(ctx, userID) + if apiErr != nil { + t.Fatalf("ListDevices: %v", apiErr) + } + if len(list) != 1 { + t.Fatalf("expected 1 device after re-register, got %d", len(list)) + } + + // Delete → list drops to 0, audit row exists, revoker called. + if apiErr := svc.DeleteDevice(ctx, userID, devUUID); apiErr != nil { + t.Fatalf("DeleteDevice: %v", apiErr) + } + list, _ = svc.ListDevices(ctx, userID) + if len(list) != 0 { + t.Fatalf("expected 0 devices after delete, got %d", len(list)) + } + + var auditCount int + db.QueryRow(`SELECT COUNT(1) FROM audit_log WHERE action='device.delete' AND actor=? AND target=?`, + fmt.Sprintf("user:%d", userID), "device:"+devUUID).Scan(&auditCount) + if auditCount != 1 { + t.Errorf("expected 1 device.delete audit row, got %d", auditCount) + } + + if len(revoker.Calls) != 1 { + t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls)) + } + if revoker.Calls[0].UserID != userID || revoker.Calls[0].Reason != "device_deleted" { + t.Errorf("unexpected revoke call: %+v", revoker.Calls[0]) + } +} + +// TestDeviceLimitEnforced verifies RegisterIfAbsent rejects when the cap is hit. +func TestDeviceLimitEnforced(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + + userID := createUser(t, db, "free@example.com", "active") + // No subscription → free plan, max_devices 1. + plan, _ := svc.ResolvePlan(ctx, userID) + if plan.MaxDevices != 1 { + t.Fatalf("expected free max_devices 1, got %d", plan.MaxDevices) + } + + first := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "Pixel", Platform: "android", MaxDevices: 1} + if _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil { + t.Fatalf("first register: %v", apiErr) + } + + second := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "iPad", Platform: "ios", MaxDevices: 1} + _, apiErr := svc.RegisterIfAbsent(ctx, second) + if apiErr == nil { + t.Fatal("expected second register to be rejected") + } + if apiErr.Code != "DEVICE_LIMIT_EXCEEDED" { + t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", apiErr.Code) + } +} + +// TestDeleteOthersDevice verifies ownership enforcement and not-found handling. +func TestDeleteOthersDevice(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + + owner := createUser(t, db, "owner@example.com", "active") + other := createUser(t, db, "other@example.com", "active") + + devUUID := newUUID(t, db) + if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: owner, DeviceUUID: devUUID, Name: "Mac", Platform: "macos", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register: %v", apiErr) + } + + // Other user cannot delete it → 403 FORBIDDEN. + if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" { + t.Errorf("want FORBIDDEN, got %v", apiErr) + } + // Non-existent device → 404 NOT_FOUND. + if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" { + t.Errorf("want NOT_FOUND, got %v", apiErr) + } +} + +// TestBannedUserRejected verifies the resolver/middleware path rejects banned users. +func TestBannedUserRejected(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), nil) + ctx := context.Background() + + userID := createUser(t, db, "banned@example.com", "banned") + if _, apiErr := svc.ResolvePlan(ctx, userID); apiErr == nil || apiErr.Code != "ACCOUNT_BANNED" { + t.Errorf("want ACCOUNT_BANNED, got %v", apiErr) + } +} + +// TestHTTPHandlers exercises the chi routes end-to-end through the subscription +// middleware (userID injected as the JWT middleware would). +func TestHTTPHandlers(t *testing.T) { + db := setupMySQL(t) + svc := devices.NewService(devices.NewStore(db), &devices.NoopRevoker{}) + ctx := context.Background() + + userID := createUser(t, db, "http@example.com", "active") + giveSubscription(t, db, userID, "pro", "code", time.Now().UTC().Add(30*24*time.Hour)) + + devUUID := newUUID(t, db) + if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{ + UserID: userID, DeviceUUID: devUUID, Name: "Win", Platform: "windows", MaxDevices: 5, + }); apiErr != nil { + t.Fatalf("register: %v", apiErr) + } + + mw := devices.NewMiddleware(svc, nil, 0) + h := devices.NewHandler(svc) + + r := chi.NewRouter() + // Simulate the JWT auth middleware setting the user ID. + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + next.ServeHTTP(w, req.WithContext(devices.WithUserID(req.Context(), userID))) + }) + }) + r.Route("/v1/me", func(r chi.Router) { + r.Use(mw.Handler) + h.RegisterRoutes(r) + }) + + // GET /v1/me/devices + req := httptest.NewRequest(http.MethodGet, "/v1/me/devices", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("GET devices: want 200, got %d body=%s", w.Code, w.Body.String()) + } + var listResp struct { + Devices []devices.Device `json:"devices"` + } + if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil { + t.Fatalf("decode list: %v", err) + } + if len(listResp.Devices) != 1 || listResp.Devices[0].UUID != devUUID { + t.Fatalf("unexpected list: %+v", listResp.Devices) + } + + // DELETE /v1/me/devices/{id} + req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+devUUID, nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("DELETE device: want 204, got %d body=%s", w.Code, w.Body.String()) + } + + // DELETE a non-existent device → 404. + req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+newUUID(t, db), nil) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("DELETE missing device: want 404, got %d body=%s", w.Code, w.Body.String()) + } +} diff --git a/server/internal/devices/doc.go b/server/internal/devices/doc.go index c510035..c107100 100644 --- a/server/internal/devices/doc.go +++ b/server/internal/devices/doc.go @@ -1,5 +1,18 @@ -// Package devices manages user devices (name, platform, WireGuard public key). -// It enforces per-plan device-count limits and coordinates with the nodes -// package to revoke WireGuard peers when a device is removed or a -// subscription expires. +// Package devices manages user devices (uuid, name, platform, last_seen) and +// owns subscription resolution for the rest of the API. +// +// Devices are registered implicitly on first connect (RegisterIfAbsent) and +// removed via DELETE /v1/me/devices/{id}. Removal hard-deletes the row, writes +// an audit_log entry, and triggers per-user data-plane credential recall +// through the CredentialRevoker interface (satisfied by nodes.Hub in module #5). +// Because the credential model is per-user (users.dp_uuid; the node side has no +// device dimension, doc/02 §3.2), "revoke a device" is realised as recalling +// the owning user's credential. +// +// SubscriptionMiddleware resolves the caller's highest-tier non-expired +// subscription (falling back to the free plan) and injects the plan +// {plan_code, expires_at, max_devices, daily_minutes, ad_gate} into the request +// context, where nodes (catalogue filtering / connect) and usage (daily caps) +// read it via PlanFromCtx. Plan numbers follow design/CLAUDE.md §7: +// free 1 / pro 5 / team 10 devices. package devices diff --git a/server/internal/devices/handler.go b/server/internal/devices/handler.go new file mode 100644 index 0000000..be92e64 --- /dev/null +++ b/server/internal/devices/handler.go @@ -0,0 +1,68 @@ +package devices + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/wangjia/pangolin/server/internal/apierr" +) + +// Handler serves the account device endpoints. It assumes the JWT auth +// middleware (module #2) has set CtxKeyUserID on the request context. +type Handler struct { + svc *Service +} + +// NewHandler creates a Handler backed by svc. +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +// RegisterRoutes mounts the device routes on r under the caller's chosen prefix +// (the API mounts these beneath /v1/me): +// +// GET /devices +// DELETE /devices/{id} +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Get("/devices", h.ListDevices) + r.Delete("/devices/{id}", h.DeleteDevice) +} + +type listDevicesResponse struct { + Devices []Device `json:"devices"` +} + +// ListDevices handles GET /v1/me/devices. +func (h *Handler) ListDevices(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + devices, apiErr := h.svc.ListDevices(r.Context(), userID) + if apiErr != nil { + apierr.WriteJSON(w, StatusForError(apiErr), apiErr) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(listDevicesResponse{Devices: devices}) +} + +// DeleteDevice handles DELETE /v1/me/devices/{id}. +func (h *Handler) DeleteDevice(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + deviceUUID := chi.URLParam(r, "id") + if apiErr := h.svc.DeleteDevice(r.Context(), userID, deviceUUID); apiErr != nil { + apierr.WriteJSON(w, StatusForError(apiErr), apiErr) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/internal/devices/middleware.go b/server/internal/devices/middleware.go new file mode 100644 index 0000000..3d10e65 --- /dev/null +++ b/server/internal/devices/middleware.go @@ -0,0 +1,102 @@ +package devices + +import ( + "net/http" + "time" + + "github.com/redis/go-redis/v9" + "github.com/wangjia/pangolin/server/internal/apierr" +) + +// Middleware resolves the caller's effective subscription and injects it into +// the request context. It MUST run after the JWT auth middleware (module #2), +// which is responsible for setting CtxKeyUserID. +type Middleware struct { + svc *Service + + // rdb and cacheTTL reserve a future 60s per-user plan cache. When cacheTTL + // > 0 and rdb != nil the resolved plan could be cached under + // "sub:plan:"; this is intentionally left disabled (cacheTTL == 0) + // for the initial direct-DB implementation (subscription volume is low). + // NOTE: a cache must not mask account bans — ban status would need a + // separate, uncached check before serving any cached plan. + rdb *redis.Client + cacheTTL time.Duration +} + +// NewMiddleware creates the subscription-resolving middleware. Pass rdb=nil and +// cacheTTL=0 to use the direct-DB path (current default). +func NewMiddleware(svc *Service, rdb *redis.Client, cacheTTL time.Duration) *Middleware { + return &Middleware{svc: svc, rdb: rdb, cacheTTL: cacheTTL} +} + +// Handler is the net/http middleware. It writes a JSON apierr and stops the +// chain on failure (missing user / banned / internal error); otherwise it +// injects the resolved Plan and calls next. +func (m *Middleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID, ok := UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + + plan, apiErr := m.svc.ResolvePlan(r.Context(), userID) + if apiErr != nil { + apierr.WriteJSON(w, StatusForError(apiErr), apiErr) + return + } + + ctx := WithPlan(r.Context(), plan) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// -------------------------------------------------------------------------- +// Helpers consumed by other modules (nodes catalogue filtering / connect, etc.) +// -------------------------------------------------------------------------- + +// CheckDeviceQuota returns a bilingual error when currentDevices already meets +// or exceeds the plan's device cap, otherwise nil. +func CheckDeviceQuota(p Plan, currentDevices int) *apierr.Error { + if p.MaxDevices > 0 && currentDevices >= p.MaxDevices { + return errDeviceLimit(p.MaxDevices) + } + return nil +} + +// RequirePaidTier returns a 403 error when the plan is the free tier; nil for +// pro/team. Used to gate paid-only nodes and features. +func RequirePaidTier(p Plan) *apierr.Error { + if planTier(p.PlanCode) < planTier("pro") { + return &apierr.Error{ + Code: "PAID_TIER_REQUIRED", + MessageZH: "该功能仅限付费会员,请升级后使用", + MessageEn: "This feature requires a paid plan. Please upgrade to continue.", + } + } + return nil +} + +// StatusForError maps an apierr.Error code to an HTTP status code. +func StatusForError(e *apierr.Error) int { + if e == nil { + return http.StatusOK + } + switch e.Code { + case "UNAUTHORIZED": + return http.StatusUnauthorized + case "FORBIDDEN", "ACCOUNT_BANNED", "PAID_TIER_REQUIRED": + return http.StatusForbidden + case "NOT_FOUND": + return http.StatusNotFound + case "DEVICE_LIMIT_EXCEEDED": + return http.StatusForbidden + case "BAD_REQUEST": + return http.StatusBadRequest + case "INTERNAL_ERROR": + return http.StatusInternalServerError + default: + return http.StatusBadRequest + } +} diff --git a/server/internal/devices/service.go b/server/internal/devices/service.go new file mode 100644 index 0000000..ae4ed35 --- /dev/null +++ b/server/internal/devices/service.go @@ -0,0 +1,385 @@ +package devices + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/wangjia/pangolin/server/internal/apierr" +) + +// CredentialRevoker abstracts the node-side credential recall triggered when a +// device is removed. +// +// The data-plane credential model is per-user (users.dp_uuid, doc/02 §3.2): the +// node side has no device dimension, so "revoke a device" is realised as +// "recall/re-issue the owning user's credential". The interface is defined here +// (consumer side) to avoid an import cycle with the nodes package; module #5's +// nodes.Hub will satisfy it, with RevokeForUser fronting Hub.Push(RevokeCredential). +type CredentialRevoker interface { + // RevokeForUser instructs node agents to recall (and lazily re-issue) the + // data-plane credential for userID. reason is a short machine tag, e.g. + // "device_deleted", used for audit/telemetry on the node side. + RevokeForUser(ctx context.Context, userID int64, reason string) error +} + +// NoopRevoker is the default CredentialRevoker used until module #5 lands. +// It records the last call so callers/tests can assert on it. +type NoopRevoker struct { + Calls []RevokeCall +} + +// RevokeCall captures the arguments of a RevokeForUser invocation. +type RevokeCall struct { + UserID int64 + Reason string +} + +// RevokeForUser implements CredentialRevoker by recording the call. +func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason string) error { + n.Calls = append(n.Calls, RevokeCall{UserID: userID, Reason: reason}) + return nil +} + +// Service implements the devices business logic. +type Service struct { + store *Store + revoker CredentialRevoker +} + +// NewService creates a Service. If revoker is nil a NoopRevoker is used so the +// module can be wired and tested before module #5 (nodes.Hub) is available. +func NewService(store *Store, revoker CredentialRevoker) *Service { + if revoker == nil { + revoker = &NoopRevoker{} + } + return &Service{store: store, revoker: revoker} +} + +// Device is the API representation of a registered device. +type Device struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Platform string `json:"platform"` + LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen +} + +func toAPIDevice(d DeviceRow) Device { + out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform} + if d.LastSeen.Valid { + s := d.LastSeen.Time.UTC().Format(time.RFC3339) + out.LastSeen = &s + } + return out +} + +// ListDevices returns the user's devices. +func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *apierr.Error) { + rows, err := svc.store.ListByUser(ctx, userID) + if err != nil { + return nil, apierr.ErrInternal + } + out := make([]Device, 0, len(rows)) + for _, r := range rows { + out = append(out, toAPIDevice(r)) + } + return out, nil +} + +// RegisterInput carries the inputs for implicit device registration, called by +// nodes.connect when a client presents a device_id. +type RegisterInput struct { + UserID int64 + DeviceUUID string + Name string // client-reported, may come from UA; truncated to 64 runes + Platform string // ios | android | windows | macos + MaxDevices int // plan cap, from the resolved Plan (PlanFromCtx) +} + +// RegisterIfAbsent registers a device on first sight and refreshes last_seen on +// subsequent sights. The device count is checked against MaxDevices before +// inserting a brand-new device. Per-user serialization is achieved by locking +// the users row for the duration of the transaction. +func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*Device, *apierr.Error) { + uuid := strings.TrimSpace(in.DeviceUUID) + if uuid == "" { + return nil, apierr.ErrBadRequest + } + platform, ok := normalizePlatform(in.Platform) + if !ok { + return nil, apierr.ErrBadRequest + } + name := normalizeName(in.Name, platform) + + tx, err := svc.store.BeginTx(ctx) + if err != nil { + return nil, apierr.ErrInternal + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + // Lock the owning user to serialize concurrent registrations. + exists, status, err := svc.store.lockUser(ctx, tx, in.UserID) + if err != nil { + return nil, apierr.ErrInternal + } + if !exists { + return nil, apierr.ErrUnauthorized + } + if status == "banned" { + return nil, apierr.ErrAccountBanned + } + + existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) + if err != nil { + return nil, apierr.ErrInternal + } + if existing != nil { + if existing.UserID != in.UserID { + // UUID is client-generated; a collision across users is treated as + // a conflict rather than silently rebinding the device. + return nil, apierr.ErrForbidden + } + if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID); err != nil { + return nil, apierr.ErrInternal + } + if err := tx.Commit(); err != nil { + return nil, apierr.ErrInternal + } + committed = true + d := toAPIDevice(*existing) + return &d, nil + } + + // New device: enforce the plan device cap. + count, err := svc.store.countDevicesTx(ctx, tx, in.UserID) + if err != nil { + return nil, apierr.ErrInternal + } + if in.MaxDevices > 0 && count >= in.MaxDevices { + return nil, errDeviceLimit(in.MaxDevices) + } + + row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform) + if err != nil { + return nil, apierr.ErrInternal + } + if err := svc.store.writeAuditLogTx(ctx, tx, + fmt.Sprintf("user:%d", in.UserID), "device.register", "device:"+uuid, + deviceAuditMeta(name, platform)); err != nil { + _ = err // audit failure must not abort the business transaction + } + if err := tx.Commit(); err != nil { + return nil, apierr.ErrInternal + } + committed = true + + d := toAPIDevice(*row) + return &d, nil +} + +// DeleteDevice hard-deletes a device (transactionally, with an audit_log entry) +// and then triggers per-user credential recall on the node side. +// +// - device not found → 404 NOT_FOUND +// - device owned by another user → 403 FORBIDDEN (does not delete) +func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error { + uuid := strings.TrimSpace(deviceUUID) + if uuid == "" { + return apierr.ErrBadRequest + } + + tx, err := svc.store.BeginTx(ctx) + if err != nil { + return apierr.ErrInternal + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + dev, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid) + if err != nil { + return apierr.ErrInternal + } + if dev == nil { + return apierr.ErrNotFound + } + if dev.UserID != userID { + return apierr.ErrForbidden + } + + if err := svc.store.deleteDeviceTx(ctx, tx, dev.ID); err != nil { + return apierr.ErrInternal + } + if err := svc.store.writeAuditLogTx(ctx, tx, + fmt.Sprintf("user:%d", userID), "device.delete", "device:"+uuid, + deviceAuditMeta(dev.Name, dev.Platform)); err != nil { + _ = err // audit failure must not abort the delete + } + if err := tx.Commit(); err != nil { + return apierr.ErrInternal + } + committed = true + + // Recall the user's data-plane credential. Per the dp_uuid model this is a + // per-user operation (no device dimension on the node side). Best-effort: + // the device is already removed; a transient revoke failure is reconciled + // by the node agent's periodic resync, so we do not fail the request. + if err := svc.revoker.RevokeForUser(ctx, userID, "device_deleted"); err != nil { + _ = err + } + return nil +} + +// SubscriptionInfo is the /v1/me subscription summary provided by this module. +type SubscriptionInfo struct { + PlanCode string `json:"plan_code"` + ExpiresAt *string `json:"expires_at"` // RFC 3339 UTC; null for free fallback + Source string `json:"source"` +} + +// SubscriptionSummary resolves the user's effective plan and returns the /v1/me +// subscription summary. +func (svc *Service) SubscriptionSummary(ctx context.Context, userID int64) (*SubscriptionInfo, *apierr.Error) { + p, apiErr := svc.ResolvePlan(ctx, userID) + if apiErr != nil { + return nil, apiErr + } + info := &SubscriptionInfo{PlanCode: p.PlanCode, Source: p.Source} + if p.ExpiresAt != nil { + s := p.ExpiresAt.UTC().Format(time.RFC3339) + info.ExpiresAt = &s + } + return info, nil +} + +// ResolvePlan loads the user's status + subscriptions and resolves the effective +// plan. It is the shared entry point used by SubscriptionMiddleware and +// SubscriptionSummary. +func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apierr.Error) { + status, exists, err := svc.store.GetUserStatus(ctx, userID) + if err != nil { + return Plan{}, apierr.ErrInternal + } + if !exists { + return Plan{}, apierr.ErrUnauthorized + } + subs, err := svc.store.GetSubscriptions(ctx, userID) + if err != nil { + return Plan{}, apierr.ErrInternal + } + free, err := svc.store.GetFreePlan(ctx) + if err != nil { + return Plan{}, apierr.ErrInternal + } + return resolveEffectivePlan(time.Now().UTC(), status, subs, free) +} + +// resolveEffectivePlan is the pure plan-resolution rule (no I/O), unit-tested +// directly: +// +// - banned account → ErrAccountBanned +// - among subscriptions with expires_at>now, pick the highest tier; +// ties broken by the latest expiry +// - no active subscription → free fallback (ExpiresAt nil) +// +// now must be in UTC. The boundary is strict: expires_at == now counts as expired. +func resolveEffectivePlan(now time.Time, status string, subs []effSub, free Plan) (Plan, *apierr.Error) { + if status == "banned" { + return Plan{}, apierr.ErrAccountBanned + } + + var best *effSub + for i := range subs { + s := &subs[i] + if !s.ExpiresAt.After(now) { + continue // expired (strict UTC boundary) + } + if best == nil { + best = s + continue + } + bt, st := planTier(best.PlanCode), planTier(s.PlanCode) + if st > bt || (st == bt && s.ExpiresAt.After(best.ExpiresAt)) { + best = s + } + } + + if best == nil { + return free, nil + } + + exp := best.ExpiresAt.UTC() + p := Plan{ + PlanCode: best.PlanCode, + ExpiresAt: &exp, + MaxDevices: best.MaxDevices, + AdGate: best.AdGate, + Source: best.Source, + } + if best.DailyMinutes.Valid { + m := int(best.DailyMinutes.Int64) + p.DailyMinutes = &m + } + return p, nil +} + +// -------------------------------------------------------------------------- +// helpers +// -------------------------------------------------------------------------- + +// normalizePlatform validates/canonicalizes a platform string against the +// devices.platform ENUM (ios/android/windows/macos). Note: the OpenAPI Device +// schema also lists "linux", but the DB ENUM (migration #1) does not include +// it, so "linux" is rejected until the schema is widened. +func normalizePlatform(s string) (string, bool) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "ios": + return "ios", true + case "android": + return "android", true + case "windows": + return "windows", true + case "macos": + return "macos", true + } + return "", false +} + +// normalizeName trims the client-reported name, falls back to the platform when +// empty, and truncates to the devices.name VARCHAR(64) limit (by rune). +func normalizeName(name, platform string) string { + name = strings.TrimSpace(name) + if name == "" { + name = platform + } + r := []rune(name) + if len(r) > 64 { + name = string(r[:64]) + } + return name +} + +// deviceAuditMeta builds a compact JSON meta blob for audit_log. +func deviceAuditMeta(name, platform string) string { + b, _ := json.Marshal(map[string]string{"name": name, "platform": platform}) + return string(b) +} + +// errDeviceLimit builds a bilingual device-cap error that embeds the limit. +func errDeviceLimit(max int) *apierr.Error { + return &apierr.Error{ + Code: "DEVICE_LIMIT_EXCEEDED", + MessageZH: fmt.Sprintf("设备数量已达上限(%d 台),请先在其他设备上退出后重试", max), + MessageEn: fmt.Sprintf("Device limit reached (%d devices). Please remove another device and try again.", max), + } +} diff --git a/server/internal/devices/service_test.go b/server/internal/devices/service_test.go new file mode 100644 index 0000000..95ebbcb --- /dev/null +++ b/server/internal/devices/service_test.go @@ -0,0 +1,256 @@ +package devices + +import ( + "context" + "database/sql" + "strings" + "testing" + "time" +) + +// freePlan is the standard free fallback used in resolver tests +// (numbers per design/CLAUDE.md §7). +func freePlan() Plan { + m := 10 + return Plan{PlanCode: "free", MaxDevices: 1, DailyMinutes: &m, AdGate: true, Source: "free"} +} + +func mkSub(code string, maxDevices int, expiresAt time.Time, source string) effSub { + return effSub{PlanCode: code, MaxDevices: maxDevices, ExpiresAt: expiresAt, Source: source} +} + +func TestResolveEffectivePlan_TrialActiveGivesPro(t *testing.T) { + now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC) + subs := []effSub{mkSub("pro", 5, now.Add(24*time.Hour), "trial")} + + got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + if got.PlanCode != "pro" || got.MaxDevices != 5 { + t.Fatalf("want pro/5, got %s/%d", got.PlanCode, got.MaxDevices) + } + if got.Source != "trial" { + t.Errorf("want source trial, got %s", got.Source) + } + if got.ExpiresAt == nil { + t.Fatal("expected non-nil expires_at") + } +} + +func TestResolveEffectivePlan_ExpiredFallsBackToFree(t *testing.T) { + now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC) + // pro trial expired one second ago. + subs := []effSub{mkSub("pro", 5, now.Add(-time.Second), "trial")} + + got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + if got.PlanCode != "free" { + t.Fatalf("want free, got %s", got.PlanCode) + } + if got.ExpiresAt != nil { + t.Errorf("free fallback should have nil expires_at, got %v", *got.ExpiresAt) + } + if got.MaxDevices != 1 { + t.Errorf("want free max_devices 1, got %d", got.MaxDevices) + } +} + +func TestResolveEffectivePlan_BannedRejected(t *testing.T) { + now := time.Now().UTC() + subs := []effSub{mkSub("pro", 5, now.Add(time.Hour), "trial")} + + _, apiErr := resolveEffectivePlan(now, "banned", subs, freePlan()) + if apiErr == nil { + t.Fatal("expected banned to be rejected") + } + if apiErr.Code != "ACCOUNT_BANNED" { + t.Errorf("want ACCOUNT_BANNED, got %s", apiErr.Code) + } +} + +func TestResolveEffectivePlan_HighestTierWins(t *testing.T) { + now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC) + subs := []effSub{ + mkSub("pro", 5, now.Add(100*24*time.Hour), "code"), // longer pro + mkSub("team", 10, now.Add(24*time.Hour), "code"), // shorter team + mkSub("free", 1, now.Add(50*24*time.Hour), "trial"), + } + + got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + // Highest tier (team) wins even though pro expires later. + if got.PlanCode != "team" || got.MaxDevices != 10 { + t.Fatalf("want team/10, got %s/%d", got.PlanCode, got.MaxDevices) + } +} + +func TestResolveEffectivePlan_SameTierLatestExpiryWins(t *testing.T) { + now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC) + early := now.Add(24 * time.Hour) + late := now.Add(48 * time.Hour) + subs := []effSub{ + mkSub("pro", 5, early, "code"), + mkSub("pro", 5, late, "code"), + } + + got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + if got.ExpiresAt == nil || !got.ExpiresAt.Equal(late) { + t.Fatalf("want latest expiry %v, got %v", late, got.ExpiresAt) + } +} + +func TestResolveEffectivePlan_UTCBoundaryStrict(t *testing.T) { + now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC) + // expires_at exactly == now must count as expired (strict >). + subs := []effSub{mkSub("pro", 5, now, "trial")} + + got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + if got.PlanCode != "free" { + t.Fatalf("expires_at == now should be expired; want free, got %s", got.PlanCode) + } + + // One microsecond after now is still active. + subs2 := []effSub{mkSub("pro", 5, now.Add(time.Microsecond), "trial")} + got2, _ := resolveEffectivePlan(now, "active", subs2, freePlan()) + if got2.PlanCode != "pro" { + t.Fatalf("expires_at just after now should be active; want pro, got %s", got2.PlanCode) + } +} + +func TestResolveEffectivePlan_NoSubsGivesFree(t *testing.T) { + got, apiErr := resolveEffectivePlan(time.Now().UTC(), "active", nil, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + if got.PlanCode != "free" || got.Source != "free" { + t.Fatalf("want free/free, got %s/%s", got.PlanCode, got.Source) + } +} + +func TestResolveEffectivePlan_DailyMinutesPropagated(t *testing.T) { + now := time.Now().UTC() + s := mkSub("pro", 5, now.Add(time.Hour), "code") + s.DailyMinutes = sql.NullInt64{} // pro: unlimited + got, _ := resolveEffectivePlan(now, "active", []effSub{s}, freePlan()) + if got.DailyMinutes != nil { + t.Errorf("pro daily_minutes should be nil (unlimited), got %d", *got.DailyMinutes) + } + + free := resolveFree(t, now) + if free.DailyMinutes == nil || *free.DailyMinutes != 10 { + t.Errorf("free daily_minutes should be 10, got %v", free.DailyMinutes) + } +} + +func resolveFree(t *testing.T, now time.Time) Plan { + t.Helper() + got, apiErr := resolveEffectivePlan(now, "active", nil, freePlan()) + if apiErr != nil { + t.Fatalf("unexpected error: %v", apiErr) + } + return got +} + +func TestCheckDeviceQuota(t *testing.T) { + free := Plan{PlanCode: "free", MaxDevices: 1} + pro := Plan{PlanCode: "pro", MaxDevices: 5} + + if err := CheckDeviceQuota(free, 0); err != nil { + t.Errorf("0 < 1 should pass, got %v", err) + } + if err := CheckDeviceQuota(free, 1); err == nil { + t.Error("1 >= 1 should be rejected") + } else if err.Code != "DEVICE_LIMIT_EXCEEDED" { + t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", err.Code) + } + if err := CheckDeviceQuota(pro, 4); err != nil { + t.Errorf("4 < 5 should pass, got %v", err) + } + if err := CheckDeviceQuota(pro, 5); err == nil { + t.Error("5 >= 5 should be rejected") + } +} + +func TestErrDeviceLimitContainsNumber(t *testing.T) { + e := errDeviceLimit(5) + if !strings.Contains(e.MessageZH, "5") { + t.Errorf("zh message should contain the limit 5: %q", e.MessageZH) + } + if !strings.Contains(e.MessageEn, "5") { + t.Errorf("en message should contain the limit 5: %q", e.MessageEn) + } +} + +func TestRequirePaidTier(t *testing.T) { + if err := RequirePaidTier(Plan{PlanCode: "free"}); err == nil { + t.Error("free should require paid tier") + } else if err.Code != "PAID_TIER_REQUIRED" { + t.Errorf("want PAID_TIER_REQUIRED, got %s", err.Code) + } + if err := RequirePaidTier(Plan{PlanCode: "pro"}); err != nil { + t.Errorf("pro should pass, got %v", err) + } + if err := RequirePaidTier(Plan{PlanCode: "team"}); err != nil { + t.Errorf("team should pass, got %v", err) + } +} + +func TestNormalizePlatform(t *testing.T) { + cases := map[string]bool{ + "ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true, + "linux": false, "": false, "blackberry": false, + } + for in, wantOK := range cases { + _, ok := normalizePlatform(in) + if ok != wantOK { + t.Errorf("normalizePlatform(%q) ok=%v, want %v", in, ok, wantOK) + } + } +} + +func TestNormalizeName(t *testing.T) { + if got := normalizeName("", "ios"); got != "ios" { + t.Errorf("empty name should fall back to platform, got %q", got) + } + if got := normalizeName(" iPhone 15 ", "ios"); got != "iPhone 15" { + t.Errorf("name should be trimmed, got %q", got) + } + long := strings.Repeat("名", 100) + if got := normalizeName(long, "ios"); len([]rune(got)) != 64 { + t.Errorf("name should be truncated to 64 runes, got %d", len([]rune(got))) + } +} + +func TestUserIDContextRoundTrip(t *testing.T) { + ctx := WithUserID(context.Background(), 42) + id, ok := UserIDFromContext(ctx) + if !ok || id != 42 { + t.Fatalf("want 42/true, got %d/%v", id, ok) + } + if _, ok := UserIDFromContext(context.Background()); ok { + t.Error("empty context should yield ok=false") + } + // userID 0 is treated as absent. + if _, ok := UserIDFromContext(WithUserID(context.Background(), 0)); ok { + t.Error("userID 0 should be treated as absent") + } +} + +func TestNoopRevokerRecordsCalls(t *testing.T) { + n := &NoopRevoker{} + _ = n.RevokeForUser(context.Background(), 7, "device_deleted") + if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" { + t.Fatalf("unexpected calls: %+v", n.Calls) + } +} diff --git a/server/internal/devices/store.go b/server/internal/devices/store.go new file mode 100644 index 0000000..2f6fc9a --- /dev/null +++ b/server/internal/devices/store.go @@ -0,0 +1,221 @@ +package devices + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// DeviceRow mirrors a `devices` table row. +type DeviceRow struct { + ID int64 + UUID string + UserID int64 + Name string + Platform string + LastSeen sql.NullTime + CreatedAt time.Time +} + +// effSub is an active-or-expired subscription joined with its plan, used by the +// pure plan resolver. expiry filtering is performed in Go (resolveEffectivePlan) +// so the UTC boundary logic is unit-testable without a database. +type effSub struct { + PlanCode string + MaxDevices int + DailyMinutes sql.NullInt64 + AdGate bool + ExpiresAt time.Time + Source string +} + +// Store wraps a *sql.DB and exposes the database operations the devices module +// needs. Methods that take 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} } + +// BeginTx starts a transaction at Read Committed isolation. Per-user +// serialization for device mutations is achieved by locking the users row +// (SELECT ... FOR UPDATE) inside the transaction. +func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { + return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) +} + +// -------------------------------------------------------------------------- +// Device queries +// -------------------------------------------------------------------------- + +// ListByUser returns all devices for userID ordered by creation time. +func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, uuid, user_id, name, platform, last_seen, created_at + FROM devices WHERE user_id=? ORDER BY created_at ASC`, + userID) + if err != nil { + return nil, fmt.Errorf("store.ListByUser: %w", err) + } + defer rows.Close() + + var out []DeviceRow + for rows.Next() { + var d DeviceRow + if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err != nil { + return nil, fmt.Errorf("store.ListByUser scan: %w", err) + } + out = append(out, d) + } + return out, rows.Err() +} + +// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx. +// Returns (nil, nil) when the device does not exist. +func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { + row := tx.QueryRowContext(ctx, + `SELECT id, uuid, user_id, name, platform, last_seen, created_at + FROM devices WHERE uuid=? FOR UPDATE`, uuid) + var d DeviceRow + if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err == sql.ErrNoRows { + return nil, nil + } else if err != nil { + return nil, fmt.Errorf("store.findDeviceByUUIDTx: %w", err) + } + return &d, nil +} + +// lockUser locks the users row to serialize per-user device mutations and +// returns the user's status. Returns (false, "", nil) when the user is absent. +func (s *Store) lockUser(ctx context.Context, tx *sql.Tx, userID int64) (exists bool, status string, err error) { + row := tx.QueryRowContext(ctx, `SELECT status FROM users WHERE id=? FOR UPDATE`, userID) + if e := row.Scan(&status); e == sql.ErrNoRows { + return false, "", nil + } else if e != nil { + return false, "", fmt.Errorf("store.lockUser: %w", e) + } + return true, status, nil +} + +// countDevicesTx counts a user's devices inside tx. +func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (int, error) { + var n int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM devices WHERE user_id=?`, userID).Scan(&n); err != nil { + return 0, fmt.Errorf("store.countDevicesTx: %w", err) + } + return n, nil +} + +// insertDeviceTx inserts a new device row inside tx and returns it. +func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform string) (*DeviceRow, error) { + res, err := tx.ExecContext(ctx, + `INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at) + VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, + uuid, userID, name, platform) + if err != nil { + return nil, fmt.Errorf("store.insertDeviceTx: %w", err) + } + id, _ := res.LastInsertId() + return &DeviceRow{ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform}, nil +} + +// touchLastSeenTx updates a device's last_seen to now inside tx. +func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { + _, err := tx.ExecContext(ctx, + `UPDATE devices SET last_seen=UTC_TIMESTAMP(6) WHERE id=?`, deviceID) + if err != nil { + return fmt.Errorf("store.touchLastSeenTx: %w", err) + } + return nil +} + +// deleteDeviceTx hard-deletes a device row inside tx. +func (s *Store) deleteDeviceTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, deviceID); err != nil { + return fmt.Errorf("store.deleteDeviceTx: %w", err) + } + return nil +} + +// -------------------------------------------------------------------------- +// Subscription / plan queries (for the subscription middleware & /me summary) +// -------------------------------------------------------------------------- + +// GetUserStatus returns a user's account status ("active"/"banned"). +// exists is false when no such user row is present. +func (s *Store) GetUserStatus(ctx context.Context, userID int64) (status string, exists bool, err error) { + row := s.db.QueryRowContext(ctx, `SELECT status FROM users WHERE id=?`, userID) + if e := row.Scan(&status); e == sql.ErrNoRows { + return "", false, nil + } else if e != nil { + return "", false, fmt.Errorf("store.GetUserStatus: %w", e) + } + return status, true, nil +} + +// GetSubscriptions returns all subscriptions for userID joined with their plan. +// Expiry filtering is intentionally left to resolveEffectivePlan. +func (s *Store) GetSubscriptions(ctx context.Context, userID int64) ([]effSub, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT p.code, p.max_devices, p.daily_minutes, p.ad_gate, s.expires_at, s.source + FROM subscriptions s JOIN plans p ON p.id=s.plan_id + WHERE s.user_id=?`, userID) + if err != nil { + return nil, fmt.Errorf("store.GetSubscriptions: %w", err) + } + defer rows.Close() + + var out []effSub + for rows.Next() { + var e effSub + if err := rows.Scan(&e.PlanCode, &e.MaxDevices, &e.DailyMinutes, &e.AdGate, &e.ExpiresAt, &e.Source); err != nil { + return nil, fmt.Errorf("store.GetSubscriptions scan: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + +// GetFreePlan loads the free plan row (the no-subscription fallback). +func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) { + row := s.db.QueryRowContext(ctx, + `SELECT max_devices, daily_minutes, ad_gate FROM plans WHERE code='free'`) + var maxDevices int + var dailyMinutes sql.NullInt64 + var adGate bool + if err := row.Scan(&maxDevices, &dailyMinutes, &adGate); err != nil { + return Plan{}, fmt.Errorf("store.GetFreePlan: %w", err) + } + p := Plan{ + PlanCode: "free", + MaxDevices: maxDevices, + AdGate: adGate, + Source: "free", + } + if dailyMinutes.Valid { + m := int(dailyMinutes.Int64) + p.DailyMinutes = &m + } + return p, nil +} + +// -------------------------------------------------------------------------- +// Audit log +// -------------------------------------------------------------------------- + +// writeAuditLogTx inserts an audit_log row inside tx. +func (s *Store) writeAuditLogTx(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error { + if metaJSON == "" { + metaJSON = "null" + } + _, err := tx.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.writeAuditLogTx: %w", err) + } + return nil +}