diff --git a/production/backend/cmd/server/main.go b/production/backend/cmd/server/main.go index 9b8d341..342e3fc 100644 --- a/production/backend/cmd/server/main.go +++ b/production/backend/cmd/server/main.go @@ -31,6 +31,7 @@ import ( "production/internal/imei" "production/internal/inventory" "production/internal/kkm/kkmserver" + "production/internal/license" "production/internal/loyalty" "production/internal/manufacture" "production/internal/maxbot" @@ -237,6 +238,11 @@ func main() { // /api/module-control/* routes (see internal/modulecontrol's doc // comment on why a maintenance flag must never block its own toggle). app.Use(moduleControlHandler.Middleware()) + licenseSource := func() (string, time.Time, error) { + s, err := settings.Fetch(context.Background(), pgPool) + return s.LicenseKey, s.TrialStartedAt, err + } + app.Use(license.Middleware(licenseSource)) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"ok": true}) @@ -645,6 +651,9 @@ func main() { // settingsPerm — see the comment on its declaration above; still the // most sensitive gate, holds live API keys/bot tokens. + // Open to any staff role (not settingsPerm) — the trial countdown banner + // is for everyone, not just the owner who manages the rest of Settings. + api.Get("/license/status", staffAuth, staffLimit, license.StatusHandler(licenseSource)) api.Get("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Get) api.Put("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Update) diff --git a/production/backend/internal/license/license.go b/production/backend/internal/license/license.go new file mode 100644 index 0000000..6ecbb29 --- /dev/null +++ b/production/backend/internal/license/license.go @@ -0,0 +1,85 @@ +// Package license verifies purchased Aura CRM licenses entirely offline — +// no server to phone home to, no crash loop if that server is ever +// unreachable (see this repo's own history for exactly that failure mode +// in a different product this business runs). A license key is a JSON +// payload signed with an Ed25519 private key the vendor keeps offline +// (see tools/gen-license); this package only ever holds the matching +// PUBLIC key, so shipping this source (or the compiled binary) to a +// customer can never leak the ability to mint new licenses — verifying and +// signing use different keys, unlike an HMAC scheme where they'd be the +// same secret. +package license + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strings" + "time" +) + +// publicKeyHex is safe to be public — see package doc comment. Generated +// once per vendor; changing it invalidates every license issued under the +// old key. +const publicKeyHex = "e1f8a79b84ad09cf357ae0548fd2bb77184a33eca9516dee67903b784245e99f" + +var ErrInvalidSignature = errors.New("license: invalid signature") +var ErrMalformed = errors.New("license: malformed key") + +// Payload is what a license key actually asserts. ExpiresAt nil means a +// perpetual license (paid in full, not a subscription) — Valid() treats +// that as never expiring. +type Payload struct { + Customer string `json:"customer"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// Valid reports whether this payload's own terms are currently satisfied — +// it does NOT re-verify the signature (Parse already did that; a Payload +// only ever exists after a successful Parse). +func (p *Payload) Valid() bool { + return p.ExpiresAt == nil || p.ExpiresAt.After(time.Now()) +} + +// Parse verifies a license key's Ed25519 signature and decodes its +// payload. Format: base64(payload-json) + "." + base64(signature-over-the- +// undecoded-payload-b64-string) — signing the base64 text rather than the +// raw JSON bytes means there's exactly one canonical byte sequence to sign +// and verify, no risk of a JSON re-serialization producing different bytes +// on the two sides. +func Parse(key string) (*Payload, error) { + key = strings.TrimSpace(key) + parts := strings.SplitN(key, ".", 2) + if len(parts) != 2 { + return nil, ErrMalformed + } + payloadB64, sigB64 := parts[0], parts[1] + + sig, err := base64.RawURLEncoding.DecodeString(sigB64) + if err != nil { + return nil, ErrMalformed + } + + pubKey, err := hex.DecodeString(publicKeyHex) + if err != nil { + // Only possible if publicKeyHex itself was edited to something + // invalid — a build-time bug, not a runtime/user-input error. + return nil, errors.New("license: invalid embedded public key") + } + if !ed25519.Verify(ed25519.PublicKey(pubKey), []byte(payloadB64), sig) { + return nil, ErrInvalidSignature + } + + payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64) + if err != nil { + return nil, ErrMalformed + } + var p Payload + if err := json.Unmarshal(payloadJSON, &p); err != nil { + return nil, ErrMalformed + } + return &p, nil +} diff --git a/production/backend/internal/license/license_test.go b/production/backend/internal/license/license_test.go new file mode 100644 index 0000000..856f803 --- /dev/null +++ b/production/backend/internal/license/license_test.go @@ -0,0 +1,63 @@ +package license + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +// testKeyPair generates a fresh Ed25519 pair and signs payload with the +// resulting private key — Parse itself always verifies against the +// package's real embedded publicKeyHex, so these helpers exist only to +// exercise the malformed/tampered paths that don't need a genuine +// signature to fail correctly. +func signWithKey(priv ed25519.PrivateKey, p Payload) string { + payloadJSON, _ := json.Marshal(p) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + sig := ed25519.Sign(priv, []byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + return payloadB64 + "." + sigB64 +} + +func TestParseRejectsSignatureFromWrongKey(t *testing.T) { + _, wrongPriv, _ := ed25519.GenerateKey(nil) + key := signWithKey(wrongPriv, Payload{Customer: "Someone"}) + + _, err := Parse(key) + if err != ErrInvalidSignature { + t.Errorf("Parse() err = %v, want ErrInvalidSignature", err) + } +} + +func TestParseRejectsMalformedInput(t *testing.T) { + cases := []string{"", "no-dot-here", "not-base64.also-not-base64", "..", "a.b.c"} + for _, c := range cases { + if _, err := Parse(c); err == nil { + t.Errorf("Parse(%q) = nil error, want an error", c) + } + } +} + +func TestPayloadValidPerpetualNeverExpires(t *testing.T) { + p := Payload{Customer: "X", IssuedAt: time.Now()} + if !p.Valid() { + t.Error("Valid() = false for a nil-ExpiresAt (perpetual) payload, want true") + } +} + +func TestPayloadValidRespectsExpiry(t *testing.T) { + past := time.Now().Add(-time.Hour) + future := time.Now().Add(time.Hour) + + expired := Payload{ExpiresAt: &past} + if expired.Valid() { + t.Error("Valid() = true for an already-expired payload, want false") + } + + current := Payload{ExpiresAt: &future} + if !current.Valid() { + t.Error("Valid() = false for a not-yet-expired payload, want true") + } +} diff --git a/production/backend/internal/license/middleware.go b/production/backend/internal/license/middleware.go new file mode 100644 index 0000000..1032718 --- /dev/null +++ b/production/backend/internal/license/middleware.go @@ -0,0 +1,84 @@ +package license + +import ( + "strings" + "time" + + "github.com/gofiber/fiber/v2" +) + +// exemptPrefixes stay reachable even when the trial has run out with no +// valid license: /settings so an owner can actually paste a key in to +// unlock everything else, and everything already public/unauthenticated +// (client order tracking, inbound webhooks, core's maintenance control) — +// gating those too would mean a shop's existing customers lose their +// tracking link, and core losing the ability to flip this module into +// maintenance mode, over a licensing lapse that has nothing to do with them. +var exemptPrefixes = []string{ + "/api/settings", + "/api/license/status", + "/api/public/", + "/api/webhooks/", + "/api/track", + "/api/module-control/", +} + +// Source reads the two values Check needs. Deliberately not a direct +// dependency on internal/settings — that package needs license.Parse (to +// validate a key at save time), and license needing settings back would be +// an import cycle. main.go wires the real settings.Fetch in as this +// closure; that's the only place both packages need to be known at once. +type Source func() (licenseKey string, trialStartedAt time.Time, err error) + +// Middleware gates the rest of the API — the actual CRM functionality — +// behind Check(...).Allowed(). Register it before the route groups so it +// runs on every request (same pattern as internal/modulecontrol's own +// global middleware). +func Middleware(source Source) fiber.Handler { + return func(c *fiber.Ctx) error { + path := c.Path() + for _, prefix := range exemptPrefixes { + if strings.HasPrefix(path, prefix) { + return c.Next() + } + } + + licenseKey, trialStartedAt, err := source() + if err != nil { + // Fail open on our own DB read failing — that's a different, + // already-loud problem (nothing works without settings/DB + // anyway); it must not also masquerade as a licensing lockout. + return c.Next() + } + + if Check(licenseKey, trialStartedAt).Allowed() { + return c.Next() + } + + return c.Status(402).JSON(fiber.Map{ + "error": "trial_expired", + "message": "Пробный период закончился. Введите лицензионный ключ в Настройках, чтобы продолжить работу.", + }) + } +} + +// StatusHandler is a small standalone endpoint (not gated by +// settingsPerm — every staff member sees the trial countdown, not just the +// owner who manages the rest of Settings) so the frontend can show a +// banner without fetching the full Settings payload. Deliberately excludes +// Payload — a customer name isn't something every staff login needs to see. +func StatusHandler(source Source) fiber.Handler { + return func(c *fiber.Ctx) error { + licenseKey, trialStartedAt, err := source() + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": "internal error"}) + } + status := Check(licenseKey, trialStartedAt) + return c.JSON(fiber.Map{ + "licensed": status.Licensed, + "trial_active": status.TrialActive, + "trial_days_left": status.TrialDaysLeft, + "allowed": status.Allowed(), + }) + } +} diff --git a/production/backend/internal/license/status.go b/production/backend/internal/license/status.go new file mode 100644 index 0000000..2f3b7c0 --- /dev/null +++ b/production/backend/internal/license/status.go @@ -0,0 +1,55 @@ +package license + +import "time" + +// TrialDuration is deliberately not configurable — an owner who could +// extend their own trial from Settings would defeat the point of having +// one. Change this constant and rebuild if the vendor ever wants a +// different trial length for a new release. +const TrialDuration = 7 * 24 * time.Hour + +// Status is the outcome of checking a license key + trial window together — +// exactly what a request-gating middleware and the Settings UI both need, +// computed once so they can't disagree with each other. +type Status struct { + Licensed bool + TrialActive bool + TrialDaysLeft int + Payload *Payload // nil unless Licensed +} + +// Allowed is the actual gate condition: a valid license OR still inside the +// trial window. Either one is sufficient. +func (s Status) Allowed() bool { + return s.Licensed || s.TrialActive +} + +// Check combines a (possibly empty/invalid) stored license key with the +// trial start timestamp into one Status. Never returns an error — an +// invalid/expired/malformed key just means Licensed=false, falling back to +// whatever the trial window says, same as no key at all. +func Check(licenseKey string, trialStartedAt time.Time) Status { + var s Status + + if licenseKey != "" { + if p, err := Parse(licenseKey); err == nil && p.Valid() { + s.Licensed = true + s.Payload = p + } + } + + trialEnd := trialStartedAt.Add(TrialDuration) + if remaining := time.Until(trialEnd); remaining > 0 { + s.TrialActive = true + // Ceiling division so "23 hours left" still reads as 1 day, not 0 — + // an owner watching the countdown hit zero without a key entered + // should never see it jump straight from 1 to a hard lock with no + // "0 days left, expires today" warning in between. + s.TrialDaysLeft = int(remaining / (24 * time.Hour)) + if remaining%(24*time.Hour) > 0 { + s.TrialDaysLeft++ + } + } + + return s +} diff --git a/production/backend/internal/license/status_test.go b/production/backend/internal/license/status_test.go new file mode 100644 index 0000000..608d483 --- /dev/null +++ b/production/backend/internal/license/status_test.go @@ -0,0 +1,62 @@ +package license + +import ( + "testing" + "time" +) + +func TestCheckNoKeyWithinTrialWindow(t *testing.T) { + s := Check("", time.Now().Add(-2*24*time.Hour)) + + if s.Licensed { + t.Error("Licensed = true with no key, want false") + } + if !s.TrialActive { + t.Error("TrialActive = false 2 days into a 7-day trial, want true") + } + if !s.Allowed() { + t.Error("Allowed() = false during active trial, want true") + } +} + +func TestCheckNoKeyTrialExpired(t *testing.T) { + s := Check("", time.Now().Add(-8*24*time.Hour)) + + if s.TrialActive { + t.Error("TrialActive = true 8 days into a 7-day trial, want false") + } + if s.Allowed() { + t.Error("Allowed() = true after trial expiry with no license, want false") + } +} + +func TestCheckInvalidKeyFallsBackToTrial(t *testing.T) { + s := Check("garbage-not-a-license-key", time.Now()) + + if s.Licensed { + t.Error("Licensed = true for an unparseable key, want false") + } + if !s.Allowed() { + t.Error("Allowed() = false with an invalid key but fresh trial, want true (trial covers it)") + } +} + +func TestCheckTrialDaysLeftRoundsUp(t *testing.T) { + // 23 hours remaining should still read as 1 day left, not 0 — see + // status.go's own comment on why ceiling division matters here. + trialStart := time.Now().Add(-(TrialDuration - 23*time.Hour)) + s := Check("", trialStart) + + if s.TrialDaysLeft != 1 { + t.Errorf("TrialDaysLeft = %d with 23h remaining, want 1", s.TrialDaysLeft) + } +} + +func TestCheckTrialDaysLeftExactDays(t *testing.T) { + trialStart := time.Now().Add(-(TrialDuration - 3*24*time.Hour)) + s := Check("", trialStart) + + if s.TrialDaysLeft != 3 { + t.Errorf("TrialDaysLeft = %d with exactly 3 days remaining, want 3", s.TrialDaysLeft) + } +} diff --git a/production/backend/internal/settings/handler.go b/production/backend/internal/settings/handler.go index e6bc9d8..be27873 100644 --- a/production/backend/internal/settings/handler.go +++ b/production/backend/internal/settings/handler.go @@ -9,6 +9,7 @@ import ( "strconv" "production/internal/auth" + "production/internal/license" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5/pgxpool" @@ -183,6 +184,7 @@ type updateInput struct { ClientVkCommunityID *string `json:"client_vk_community_id"` DiaxProEmail *string `json:"diaxpro_email"` DiaxProPassword *string `json:"diaxpro_password"` + LicenseKey *string `json:"license_key"` } // Update is a partial update — every field is optional and COALESCEd @@ -233,6 +235,16 @@ func (h *Handler) Update(c *fiber.Ctx) error { return c.Status(400).JSON(fiber.Map{"error": err.Error()}) } } + // Reject an unparseable/forged key at save time — immediate feedback in + // the UI beats silently storing a string that will just fail the same + // check on every request from then on. An empty string clears the key + // (same COALESCE convention as every other nullable field here) and is + // never sent through Parse. + if body.LicenseKey != nil && *body.LicenseKey != "" { + if _, err := license.Parse(*body.LicenseKey); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid license key"}) + } + } _, err := h.db.Exec(context.Background(), ` UPDATE settings SET @@ -280,8 +292,9 @@ func (h *Handler) Update(c *fiber.Ctx) error { client_vk_community_id = COALESCE($42, client_vk_community_id), diaxpro_email = COALESCE($43, diaxpro_email), diaxpro_password = COALESCE($44, diaxpro_password), + license_key = COALESCE($45, license_key), updated_at = NOW(), - updated_by_staff_name = $45 + updated_by_staff_name = $46 WHERE id = 1`, body.BusinessName, body.BusinessINN, body.BusinessKPP, body.BusinessAddress, body.BusinessPhone, body.BusinessBankName, body.BusinessBankAccount, body.BusinessBankBIK, body.BusinessBankCorrAccount, @@ -294,6 +307,7 @@ func (h *Handler) Update(c *fiber.Ctx) error { body.MaxBotToken, body.MaxWebhookSecret, body.ClientMaxBotUsername, body.VkGroupToken, body.VkGroupID, body.VkSecretKey, body.VkConfirmationCode, body.ClientVkCommunityID, body.DiaxProEmail, body.DiaxProPassword, + body.LicenseKey, auth.StaffName(c), ) if err != nil { diff --git a/production/backend/internal/settings/settings.go b/production/backend/internal/settings/settings.go index b2977b1..88f18bb 100644 --- a/production/backend/internal/settings/settings.go +++ b/production/backend/internal/settings/settings.go @@ -32,52 +32,59 @@ const defaultGeminiModel = "gemini-2.0-flash" const defaultTGAPIBaseURL = "http://telegram-bot-api:8081" type Settings struct { - BusinessName string `json:"business_name"` - BusinessINN string `json:"business_inn"` - BusinessKPP string `json:"business_kpp"` - BusinessAddress string `json:"business_address"` - BusinessPhone string `json:"business_phone"` - BusinessBankName string `json:"business_bank_name"` - BusinessBankAccount string `json:"business_bank_account"` - BusinessBankBIK string `json:"business_bank_bik"` - BusinessBankCorrAccount string `json:"business_bank_corr_account"` - GeminiAPIKey string `json:"gemini_api_key"` - GeminiModel string `json:"gemini_model"` - TGBotToken string `json:"tg_bot_token"` - TGChatID string `json:"tg_chat_id"` - TGAPIBaseURL string `json:"tg_api_base_url"` - IMEIAPIKey string `json:"imei_api_key"` - SMSProvider string `json:"sms_provider"` - SMSAPIID string `json:"sms_api_id"` - SMSFrom string `json:"sms_from"` - SMSEnabled bool `json:"sms_enabled"` - ClientTGBotUsername string `json:"client_tg_bot_username"` - TGWebhookSecret string `json:"tg_webhook_secret"` - ClientNotifyEnabled bool `json:"client_notify_enabled"` - PublicTrackingURL string `json:"public_tracking_url"` - LoyaltyEnabled bool `json:"loyalty_enabled"` - LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"` - DefaultWarrantyDays int `json:"default_warranty_days"` - KkmEnabled bool `json:"kkm_enabled"` - KkmServerURL string `json:"kkm_server_url"` - KkmLogin string `json:"kkm_login"` - KkmPassword string `json:"kkm_password"` - KkmNumDevice string `json:"kkm_num_device"` - KkmTax string `json:"kkm_tax"` - DiscountApprovalEnabled bool `json:"discount_approval_enabled"` - DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"` - MaxBotToken string `json:"max_bot_token"` - MaxWebhookSecret string `json:"max_webhook_secret"` - ClientMaxBotUsername string `json:"client_max_bot_username"` - VkGroupToken string `json:"vk_group_token"` - VkGroupID string `json:"vk_group_id"` - VkSecretKey string `json:"vk_secret_key"` - VkConfirmationCode string `json:"vk_confirmation_code"` - ClientVkCommunityID string `json:"client_vk_community_id"` - DiaxProEmail string `json:"diaxpro_email"` - DiaxProPassword string `json:"diaxpro_password"` - UpdatedAt *time.Time `json:"updated_at"` - UpdatedByStaffName string `json:"updated_by_staff_name"` + BusinessName string `json:"business_name"` + BusinessINN string `json:"business_inn"` + BusinessKPP string `json:"business_kpp"` + BusinessAddress string `json:"business_address"` + BusinessPhone string `json:"business_phone"` + BusinessBankName string `json:"business_bank_name"` + BusinessBankAccount string `json:"business_bank_account"` + BusinessBankBIK string `json:"business_bank_bik"` + BusinessBankCorrAccount string `json:"business_bank_corr_account"` + GeminiAPIKey string `json:"gemini_api_key"` + GeminiModel string `json:"gemini_model"` + TGBotToken string `json:"tg_bot_token"` + TGChatID string `json:"tg_chat_id"` + TGAPIBaseURL string `json:"tg_api_base_url"` + IMEIAPIKey string `json:"imei_api_key"` + SMSProvider string `json:"sms_provider"` + SMSAPIID string `json:"sms_api_id"` + SMSFrom string `json:"sms_from"` + SMSEnabled bool `json:"sms_enabled"` + ClientTGBotUsername string `json:"client_tg_bot_username"` + TGWebhookSecret string `json:"tg_webhook_secret"` + ClientNotifyEnabled bool `json:"client_notify_enabled"` + PublicTrackingURL string `json:"public_tracking_url"` + LoyaltyEnabled bool `json:"loyalty_enabled"` + LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"` + DefaultWarrantyDays int `json:"default_warranty_days"` + KkmEnabled bool `json:"kkm_enabled"` + KkmServerURL string `json:"kkm_server_url"` + KkmLogin string `json:"kkm_login"` + KkmPassword string `json:"kkm_password"` + KkmNumDevice string `json:"kkm_num_device"` + KkmTax string `json:"kkm_tax"` + DiscountApprovalEnabled bool `json:"discount_approval_enabled"` + DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"` + MaxBotToken string `json:"max_bot_token"` + MaxWebhookSecret string `json:"max_webhook_secret"` + ClientMaxBotUsername string `json:"client_max_bot_username"` + VkGroupToken string `json:"vk_group_token"` + VkGroupID string `json:"vk_group_id"` + VkSecretKey string `json:"vk_secret_key"` + VkConfirmationCode string `json:"vk_confirmation_code"` + ClientVkCommunityID string `json:"client_vk_community_id"` + DiaxProEmail string `json:"diaxpro_email"` + DiaxProPassword string `json:"diaxpro_password"` + // TrialStartedAt is stamped once by migration 067 the moment this + // database is first created — see that migration's doc comment for why + // that's a reliable first-boot timestamp with no separate detection + // code. LicenseKey is empty until an owner pastes one into Settings; + // see internal/license for verification. + TrialStartedAt time.Time `json:"trial_started_at"` + LicenseKey string `json:"license_key"` + UpdatedAt *time.Time `json:"updated_at"` + UpdatedByStaffName string `json:"updated_by_staff_name"` } // Fetch reads the singleton settings row, one indexed SELECT on a 1-row @@ -109,6 +116,7 @@ func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) { COALESCE(vk_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''), COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''), COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''), + trial_started_at, COALESCE(license_key, ''), updated_at, COALESCE(updated_by_staff_name, '') FROM settings WHERE id = 1`, ).Scan( @@ -124,6 +132,7 @@ func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) { &s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername, &s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID, &s.DiaxProEmail, &s.DiaxProPassword, + &s.TrialStartedAt, &s.LicenseKey, &s.UpdatedAt, &s.UpdatedByStaffName, ) if err != nil { diff --git a/production/backend/migrations/067_license.sql b/production/backend/migrations/067_license.sql new file mode 100644 index 0000000..8975228 --- /dev/null +++ b/production/backend/migrations/067_license.sql @@ -0,0 +1,17 @@ +-- +goose Up + +-- trial_started_at is stamped once, right here, at the moment this +-- migration first runs on a fresh install — i.e. the very first time this +-- app's database is created. That's what makes it a reliable "first boot" +-- timestamp without any separate first-run-detection code: goose only ever +-- runs this migration once per database, so DEFAULT NOW() here IS the +-- trial clock starting. license_key is NULL until an owner pastes one into +-- Settings; see internal/license for verification and internal/license/ +-- middleware.go for what happens once the trial window closes with no +-- valid key. +ALTER TABLE settings ADD COLUMN trial_started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE settings ADD COLUMN license_key TEXT; + +-- +goose Down +ALTER TABLE settings DROP COLUMN trial_started_at; +ALTER TABLE settings DROP COLUMN license_key; diff --git a/production/backend/tools/gen-license/main.go b/production/backend/tools/gen-license/main.go new file mode 100644 index 0000000..32749a7 --- /dev/null +++ b/production/backend/tools/gen-license/main.go @@ -0,0 +1,92 @@ +// Command gen-license mints signed license keys for Aura CRM (see +// internal/license's package doc for the format/threat model). This is a +// vendor-side tool — it needs the Ed25519 PRIVATE key, which must never +// ship inside the product itself or this repo. Run it only on a machine +// that holds that key, e.g.: +// +// go run ./tools/gen-license -customer "ООО Ромашка" -days 365 \ +// -key /root/.aura-license/private_key.hex +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" +) + +// Mirrors internal/license.Payload field-for-field — duplicated rather +// than imported so this tool has zero dependency on the rest of the +// module and can be copy-pasted out to a vendor-only machine on its own. +type payload struct { + Customer string `json:"customer"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func main() { + customer := flag.String("customer", "", "customer/business name this license is issued to (required)") + days := flag.Int("days", 0, "days until expiry from now; 0 = perpetual license") + keyPath := flag.String("key", "", "path to the hex-encoded Ed25519 private key (required)") + flag.Parse() + + if *customer == "" || *keyPath == "" { + fmt.Fprintln(os.Stderr, "usage: gen-license -customer NAME -key PATH [-days N]") + os.Exit(1) + } + + priv, err := loadPrivateKey(*keyPath) + if err != nil { + fmt.Fprintf(os.Stderr, "gen-license: %v\n", err) + os.Exit(1) + } + + p := payload{Customer: *customer, IssuedAt: time.Now().UTC()} + if *days > 0 { + exp := p.IssuedAt.AddDate(0, 0, *days) + p.ExpiresAt = &exp + } + + key, err := sign(priv, p) + if err != nil { + fmt.Fprintf(os.Stderr, "gen-license: %v\n", err) + os.Exit(1) + } + + fmt.Println(key) +} + +func loadPrivateKey(path string) (ed25519.PrivateKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading key file: %w", err) + } + decoded, err := hex.DecodeString(strings.TrimSpace(string(raw))) + if err != nil { + return nil, fmt.Errorf("key file is not valid hex: %w", err) + } + if len(decoded) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("key file has %d bytes, expected %d (ed25519 private key)", len(decoded), ed25519.PrivateKeySize) + } + return ed25519.PrivateKey(decoded), nil +} + +// sign encodes payload the same way internal/license.Parse decodes it — +// signature covers the base64 payload TEXT, not the raw JSON bytes, so +// both sides agree on exactly one byte sequence. See that package's Parse +// doc comment for why. +func sign(priv ed25519.PrivateKey, p payload) (string, error) { + payloadJSON, err := json.Marshal(p) + if err != nil { + return "", fmt.Errorf("encoding payload: %w", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + sig := ed25519.Sign(priv, []byte(payloadB64)) + sigB64 := base64.RawURLEncoding.EncodeToString(sig) + return payloadB64 + "." + sigB64, nil +} diff --git a/production/web/src/layout/AdminLayout.jsx b/production/web/src/layout/AdminLayout.jsx index 5772498..a8bab73 100644 --- a/production/web/src/layout/AdminLayout.jsx +++ b/production/web/src/layout/AdminLayout.jsx @@ -15,6 +15,8 @@ import ShiftPromptModal from '../shifts/ShiftPromptModal' import NewEntryTypeModal from '../orders/NewEntryTypeModal' import NewOnsiteBookingModal from '../bookings/NewOnsiteBookingModal' import MultiIntakeModal from '../orders/MultiIntakeModal' +import { LicenseGate } from '../settings/LicenseGate' +import { TrialBanner } from '../settings/TrialBanner' const PAGE_TITLES = { '/kanban': 'Канбан', @@ -113,6 +115,8 @@ const AdminLayout = () => {
+ + {isMobile && sidebarOpen && (
setSidebarOpen(false)} diff --git a/production/web/src/lib/api.js b/production/web/src/lib/api.js index 70ba184..b167757 100644 --- a/production/web/src/lib/api.js +++ b/production/web/src/lib/api.js @@ -54,6 +54,14 @@ async function request(baseUrl, path, options = {}) { const data = isJson ? await res.json().catch(() => null) : null if (!res.ok) { + // internal/license's middleware 402s every non-exempt request once the + // trial has run out with no valid key — a global event (not a redirect, + // unlike 401 above) lets LicenseGate show a blocking overlay from + // wherever the user happens to be, since /api/settings itself stays + // reachable (exempt) so they can still paste a key in to clear it. + if (res.status === 402 && data?.error === 'trial_expired') { + window.dispatchEvent(new CustomEvent('license:blocked')) + } throw new ApiError(data?.error || `request failed: ${res.status}`, res.status) } return data @@ -634,6 +642,13 @@ export const settings = { update: (body) => request(PRODUCTION_URL, '/api/settings', { method: 'PUT', body: JSON.stringify(body) }), } +// Пробный период / лицензия (internal/license) — отдельный лёгкий эндпоинт +// вместо settings.get(), доступный любому staff-логину (не только owner), +// чтобы баннер триала мог показаться каждому, кто вошёл. +export const license = { + status: () => request(PRODUCTION_URL, '/api/license/status'), +} + // Кастомные поля приёмки — owner добавляет свои вопросы к форме заявки // (пломба цела? с зарядкой? и т.п.) без правки кода. list() — активные, // для формы заявки, доступен всем ролям; listAll() — включая архивные, diff --git a/production/web/src/settings/LicenseGate.jsx b/production/web/src/settings/LicenseGate.jsx new file mode 100644 index 0000000..ec26eb7 --- /dev/null +++ b/production/web/src/settings/LicenseGate.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { ShieldX } from 'lucide-react' + +// Full-screen blocking overlay — shown the moment any API call 402s with +// trial_expired (see lib/api.js's request(), which dispatches this event). +// Doesn't redirect: /settings/license itself stays reachable (exempt on +// the backend) so a staff member can paste a key in without navigating away +// first, and this overlay just sits on top until they do. +export function LicenseGate() { + const [blocked, setBlocked] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + const onBlocked = () => setBlocked(true) + window.addEventListener('license:blocked', onBlocked) + return () => window.removeEventListener('license:blocked', onBlocked) + }, []) + + if (!blocked) return null + + return ( +
+
+ +

+ Пробный период закончился +

+

+ Введите лицензионный ключ в Настройках, чтобы продолжить работу. +

+ +
+
+ ) +} diff --git a/production/web/src/settings/LicenseTab.jsx b/production/web/src/settings/LicenseTab.jsx new file mode 100644 index 0000000..0ed2d19 --- /dev/null +++ b/production/web/src/settings/LicenseTab.jsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { ShieldCheck, ShieldAlert, ShieldX, Clock } from 'lucide-react' +import * as api from '../lib/api' +import { Section, Field, SaveFooter } from './SettingsFields' +import { useSettingsForm } from './useSettingsForm' + +// Live trial/license state (internal/license) — fetched separately from +// api.settings.get() below because /api/license/status is open to any staff +// role (every login should see the countdown), while /api/settings is +// settingsPerm-gated. Two different audiences, two different endpoints. +function StatusPanel() { + const [status, setStatus] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + api.license.status() + .then((data) => { if (!cancelled) setStatus(data) }) + .catch((err) => { if (!cancelled) setError(err.message) }) + return () => { cancelled = true } + }, []) + + if (error) return

{error}

+ if (!status) return

Загрузка…

+ + const tone = status.licensed + ? { Icon: ShieldCheck, color: 'var(--accent-green, #2fae60)', text: 'Лицензия активна' } + : status.trial_active + ? { Icon: Clock, color: 'var(--accent-orange, #d98a1f)', text: `Пробный период — осталось ${status.trial_days_left} дн.` } + : { Icon: ShieldX, color: 'var(--accent-red, #d64545)', text: 'Пробный период закончился' } + + return ( +
+ +
+

{tone.text}

+ {!status.licensed && !status.trial_active && ( +

+ Введите лицензионный ключ ниже, чтобы продолжить работу без ограничений. +

+ )} +
+
+ ) +} + +// Ошибка проверки формата ключа приходит от валидации на сервере +// (license.Parse внутри handler.go) — не отдельная проверка на фронте, +// чтобы не дублировать формат base64(payload).base64(sig) в двух местах. +const LicenseTab = () => { + const { form, loading, error, saving, saved, load, set, save } = useSettingsForm(api) + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ + + {loading &&

Загрузка...

} + {error && !form &&

{error}

} + + {form && ( + <> +
+ +
+ + + + )} +
+ ) +} + +export default LicenseTab diff --git a/production/web/src/settings/TrialBanner.jsx b/production/web/src/settings/TrialBanner.jsx new file mode 100644 index 0000000..05d2b81 --- /dev/null +++ b/production/web/src/settings/TrialBanner.jsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { Clock, X } from 'lucide-react' +import * as api from '../lib/api' + +// Proactive countdown, separate from LicenseGate's reactive 402 overlay — +// this warns a few days *before* the trial actually locks anything, so +// nobody's first sign of a problem is a hard block mid-task. Only renders +// in the last 3 days of an unlicensed trial; silent otherwise. Dismissal +// is per-page-load (no persistence) — deliberately, so it comes back next +// login rather than someone dismissing it once on day 5 and never seeing +// it again before expiry. +export function TrialBanner() { + const [status, setStatus] = useState(null) + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + let cancelled = false + api.license.status() + .then((data) => { if (!cancelled) setStatus(data) }) + .catch(() => {}) // silent — LicenseGate/inline errors already cover a failed check + return () => { cancelled = true } + }, []) + + if (dismissed || !status || status.licensed || !status.trial_active || status.trial_days_left > 3) return null + + return ( +
+ + + Пробный период заканчивается через {status.trial_days_left} {status.trial_days_left === 1 ? 'день' : 'дня'} —{' '} + + ввести лицензионный ключ + + + +
+ ) +} diff --git a/production/web/src/settings/settingsTabs.js b/production/web/src/settings/settingsTabs.js index 2cab040..1b4bf37 100644 --- a/production/web/src/settings/settingsTabs.js +++ b/production/web/src/settings/settingsTabs.js @@ -1,8 +1,9 @@ -import { Settings, MonitorSmartphone, Users, KeyRound, ListChecks, Tags, Hammer, FileText, Blocks, ToggleLeft, Columns3, LayoutTemplate, Sparkles, Star, Plug } from 'lucide-react' +import { Settings, MonitorSmartphone, Users, KeyRound, ListChecks, Tags, Hammer, FileText, Blocks, ToggleLeft, Columns3, LayoutTemplate, Sparkles, Star, Plug, ShieldCheck } from 'lucide-react' import SettingsPage from './SettingsPage' import IntegrationsPage from './IntegrationsPage' import InterfaceTab from './InterfaceTab' import UpdatesTab from './UpdatesTab' +import LicenseTab from './LicenseTab' import SectionsTab from './SectionsTab' import OrderStatusesPage from './OrderStatusesPage' import PcBuildPresetsPage from './PcBuildPresetsPage' @@ -48,6 +49,7 @@ export const SETTINGS_TABS = [ { path: 'roles', label: 'Роли', icon: KeyRound, permission: 'staff', group: 'access', Component: RolesPage }, { path: 'modules', label: 'Модули', icon: Blocks, permission: 'modules', group: 'system', Component: ModulesPage }, { path: 'site-builder', label: 'Конструктор сайта', icon: LayoutTemplate, permission: 'settings', group: 'system', Component: SiteBuilderPage }, + { path: 'license', label: 'Лицензия', icon: ShieldCheck, permission: 'settings', group: 'system', Component: LicenseTab }, ] // Order here is display order — groups render top to bottom, tabs within a