feat: offline license/trial system
7-day full-access trial, then gated behind an Ed25519-signed license key (internal/license) verified entirely offline — no phone-home dependency. Middleware 402s non-exempt API routes once the trial lapses with no valid key; frontend shows a countdown banner in the last 3 days and a blocking overlay once actually gated, both pointing at the new Settings -> Лицензия tab. tools/gen-license mints keys for the vendor side (never ships the private key).
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<CartProvider>
|
||||
<div data-shop-mode={shopMode ? 'true' : undefined} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<ShiftPromptModal />
|
||||
<LicenseGate />
|
||||
<TrialBanner />
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
|
||||
@@ -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() — включая архивные,
|
||||
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(3px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px',
|
||||
}}>
|
||||
<div className="glass-panel" style={{ maxWidth: '420px', padding: '28px', textAlign: 'center' }}>
|
||||
<ShieldX size={32} style={{ color: 'var(--accent-red, #d64545)', marginBottom: '12px' }} />
|
||||
<h2 style={{ fontFamily: 'var(--font-heading)', fontSize: '1.15rem', marginBottom: '8px' }}>
|
||||
Пробный период закончился
|
||||
</h2>
|
||||
<p style={{ fontSize: '0.88rem', color: 'var(--color-text-muted)', marginBottom: '20px' }}>
|
||||
Введите лицензионный ключ в Настройках, чтобы продолжить работу.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
style={{ padding: '10px 20px', fontSize: '0.88rem' }}
|
||||
onClick={() => { setBlocked(false); navigate('/settings/license') }}
|
||||
>
|
||||
Открыть настройки лицензии
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <p style={{ fontSize: '0.85rem', color: 'var(--accent-red, #d64545)' }}>{error}</p>
|
||||
if (!status) return <p style={{ fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Загрузка…</p>
|
||||
|
||||
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 (
|
||||
<div className="glass-panel" style={{ padding: '20px', marginBottom: '18px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<tone.Icon size={22} style={{ color: tone.color, flexShrink: 0 }} />
|
||||
<div>
|
||||
<p style={{ fontSize: '0.95rem', fontWeight: 560 }}>{tone.text}</p>
|
||||
{!status.licensed && !status.trial_active && (
|
||||
<p style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>
|
||||
Введите лицензионный ключ ниже, чтобы продолжить работу без ограничений.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Ошибка проверки формата ключа приходит от валидации на сервере
|
||||
// (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 (
|
||||
<div>
|
||||
<StatusPanel />
|
||||
|
||||
{loading && <p style={{ color: 'var(--text-muted)' }}>Загрузка...</p>}
|
||||
{error && !form && <p style={{ color: 'var(--accent-red)' }}>{error}</p>}
|
||||
|
||||
{form && (
|
||||
<>
|
||||
<Section title="Лицензионный ключ" hint="Ключ выдаётся при покупке лицензии или подписки. Проверяется офлайн — интернет для этого не нужен.">
|
||||
<Field label="Ключ" field="license_key" value={form.license_key} onChange={set} placeholder="вставьте ключ сюда" />
|
||||
</Section>
|
||||
|
||||
<SaveFooter error={error} saving={saving} saved={saved} form={form} onSave={save} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LicenseTab
|
||||
@@ -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 (
|
||||
<div style={{
|
||||
background: 'color-mix(in srgb, var(--accent-orange, #d98a1f) 15%, var(--color-surface))',
|
||||
borderBottom: '1px solid var(--accent-orange, #d98a1f)',
|
||||
padding: '9px 20px', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px',
|
||||
fontSize: '0.85rem', position: 'relative',
|
||||
}}>
|
||||
<Clock size={15} style={{ color: 'var(--accent-orange, #d98a1f)', flexShrink: 0 }} />
|
||||
<span>
|
||||
Пробный период заканчивается через {status.trial_days_left} {status.trial_days_left === 1 ? 'день' : 'дня'} —{' '}
|
||||
<Link to="/settings/license" style={{ color: 'var(--color-accent-fg)', textDecoration: 'underline' }}>
|
||||
ввести лицензионный ключ
|
||||
</Link>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label="Скрыть"
|
||||
style={{ position: 'absolute', right: '14px', background: 'none', color: 'var(--color-text-muted)', padding: '2px' }}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user