Files
aura-crm/production/backend/internal/settings/handler.go
T
root 56ffca767d 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).
2026-08-21 18:44:54 +00:00

325 lines
15 KiB
Go

package settings
import (
"context"
"fmt"
"log"
"math"
"net/url"
"strconv"
"production/internal/auth"
"production/internal/license"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5/pgxpool"
)
// notify.Send builds a request to <TGAPIBaseURL>/bot<token>/sendMessage —
// an owner-editable base URL with no restriction on it would let a
// compromised owner JWT redirect every future notification (bot token
// included, in the URL path) to an attacker-controlled host, and it would
// make this backend an SSRF proxy that dials whatever internal address the
// attacker names. The only two hosts this deployment ever legitimately
// needs are the self-hosted Bot API server on the docker network (see
// docker-compose.yml's telegram-bot-api service) and Telegram's own API,
// should someone switch away from self-hosting.
var allowedTGAPIHosts = map[string]bool{
"telegram-bot-api": true,
"api.telegram.org": true,
}
// validateLoyaltyAccrualPercent rejects anything that would either fail the
// ::numeric cast with a bare 500 (garbage, NaN, Inf — same class of bug
// internal/cash's amount validation guards against) or be a nonsensical
// accrual rate (over 100% would mean a sale accrues more points than its
// own price).
func validateLoyaltyAccrualPercent(raw string) error {
if raw == "" {
return nil
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
return fmt.Errorf("loyalty_accrual_percent must be a number between 0 and 100")
}
return nil
}
func validateTGAPIBaseURL(raw string) error {
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("tg_api_base_url must be a valid absolute URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("tg_api_base_url scheme must be http or https")
}
if !allowedTGAPIHosts[u.Hostname()] {
return fmt.Errorf("tg_api_base_url host must be telegram-bot-api or api.telegram.org")
}
return nil
}
// validateDiscountApprovalThresholdPercent mirrors
// validateLoyaltyAccrualPercent above — same NOT NULL column, same
// reject-garbage-before-the-::numeric-cast reasoning. 0 is a valid,
// meaningful value here (any discount at all requires approval), unlike
// loyalty's 0 meaning "no accrual" — so nothing above 100 makes sense
// either way.
func validateDiscountApprovalThresholdPercent(raw string) error {
if raw == "" {
return nil
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
return fmt.Errorf("discount_approval_threshold_percent must be a number between 0 and 100")
}
return nil
}
// validateKkmServerURL only checks well-formedness, unlike
// validateTGAPIBaseURL's fixed host allowlist above — there's no fixed set
// of legitimate hosts here. A KkmServer instance is, by design, the
// owner's own machine on whatever network reaches it (shop LAN, a
// Tailscale address, ...), so rejecting private/local hosts would break
// the one deployment shape this field exists for.
func validateKkmServerURL(raw string) error {
if raw == "" {
return nil
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return fmt.Errorf("kkm_server_url must be a valid absolute URL")
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("kkm_server_url scheme must be http or https")
}
return nil
}
type Handler struct {
db *pgxpool.Pool
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db}
}
// Get returns the current settings as stored (post env-fallback) — secrets
// included. Owner-only at the route level (see main.go); nothing here
// masks GeminiAPIKey/TGBotToken/IMEIAPIKey before the owner who is the only
// staff role allowed to reach this endpoint.
func (h *Handler) Get(c *fiber.Ctx) error {
s, err := Fetch(context.Background(), h.db)
if err != nil {
log.Printf("settings: fetch failed: %v", err)
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(s)
}
// PublicInfo is the unauthenticated counterpart to Get — just enough
// business identity (name/INN/address/phone) for the public booking form's
// consent text and the privacy-policy page to name the actual data
// operator instead of a hardcoded placeholder, without exposing anything
// from Get's full response (AI/Telegram/SMS keys, bank requisites).
func (h *Handler) PublicInfo(c *fiber.Ctx) error {
s, err := Fetch(context.Background(), h.db)
if err != nil {
log.Printf("settings: fetch failed: %v", err)
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(fiber.Map{
"business_name": s.BusinessName,
"business_inn": s.BusinessINN,
"business_address": s.BusinessAddress,
"business_phone": s.BusinessPhone,
})
}
type updateInput 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"`
LicenseKey *string `json:"license_key"`
}
// Update is a partial update — every field is optional and COALESCEd
// against the existing row, matching order.Update/cartridge.UpdateBatch's
// own convention elsewhere in this codebase. An unset field keeps its
// current value; sending an empty string "" explicitly clears it (COALESCE
// only substitutes on SQL NULL, not on an empty non-null string) — that
// asymmetry is intentional: it's how the Settings page clears a field the
// owner wants to blank out again.
func (h *Handler) Update(c *fiber.Ctx) error {
var body updateInput
if err := c.BodyParser(&body); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "invalid request body"})
}
if body.TGAPIBaseURL != nil {
if err := validateTGAPIBaseURL(*body.TGAPIBaseURL); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
}
if body.LoyaltyAccrualPercent != nil {
// loyalty_accrual_percent is NOT NULL (no "blank" state to clear
// back to like the nullable TEXT fields above) — an owner clearing
// the input box means "0%", not "leave unchanged".
if *body.LoyaltyAccrualPercent == "" {
zero := "0"
body.LoyaltyAccrualPercent = &zero
}
if err := validateLoyaltyAccrualPercent(*body.LoyaltyAccrualPercent); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
}
if body.DefaultWarrantyDays != nil && (*body.DefaultWarrantyDays < 0 || *body.DefaultWarrantyDays > 3650) {
return c.Status(400).JSON(fiber.Map{"error": "default_warranty_days must be between 0 and 3650"})
}
if body.KkmServerURL != nil {
if err := validateKkmServerURL(*body.KkmServerURL); err != nil {
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
}
}
if body.DiscountApprovalThresholdPercent != nil {
// Same NOT-NULL-no-blank-state handling as loyalty_accrual_percent
// above — clearing the input means 0%, not "leave unchanged".
if *body.DiscountApprovalThresholdPercent == "" {
zero := "0"
body.DiscountApprovalThresholdPercent = &zero
}
if err := validateDiscountApprovalThresholdPercent(*body.DiscountApprovalThresholdPercent); err != nil {
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
business_name = COALESCE($1, business_name),
business_inn = COALESCE($2, business_inn),
business_kpp = COALESCE($3, business_kpp),
business_address = COALESCE($4, business_address),
business_phone = COALESCE($5, business_phone),
business_bank_name = COALESCE($6, business_bank_name),
business_bank_account = COALESCE($7, business_bank_account),
business_bank_bik = COALESCE($8, business_bank_bik),
business_bank_corr_account = COALESCE($9, business_bank_corr_account),
gemini_api_key = COALESCE($10, gemini_api_key),
gemini_model = COALESCE($11, gemini_model),
tg_bot_token = COALESCE($12, tg_bot_token),
tg_chat_id = COALESCE($13, tg_chat_id),
tg_api_base_url = COALESCE($14, tg_api_base_url),
imei_api_key = COALESCE($15, imei_api_key),
sms_provider = COALESCE($16, sms_provider),
sms_api_id = COALESCE($17, sms_api_id),
sms_from = COALESCE($18, sms_from),
sms_enabled = COALESCE($19, sms_enabled),
client_tg_bot_username = COALESCE($20, client_tg_bot_username),
tg_webhook_secret = COALESCE($21, tg_webhook_secret),
client_notify_enabled = COALESCE($22, client_notify_enabled),
public_tracking_url = COALESCE($23, public_tracking_url),
loyalty_enabled = COALESCE($24, loyalty_enabled),
loyalty_accrual_percent = COALESCE($25::numeric, loyalty_accrual_percent),
default_warranty_days = COALESCE($26, default_warranty_days),
kkm_enabled = COALESCE($27, kkm_enabled),
kkm_server_url = COALESCE($28, kkm_server_url),
kkm_login = COALESCE($29, kkm_login),
kkm_password = COALESCE($30, kkm_password),
kkm_num_device = COALESCE($31, kkm_num_device),
kkm_tax = COALESCE($32, kkm_tax),
discount_approval_enabled = COALESCE($33, discount_approval_enabled),
discount_approval_threshold_percent = COALESCE($34::numeric, discount_approval_threshold_percent),
max_bot_token = COALESCE($35, max_bot_token),
max_webhook_secret = COALESCE($36, max_webhook_secret),
client_max_bot_username = COALESCE($37, client_max_bot_username),
vk_group_token = COALESCE($38, vk_group_token),
vk_group_id = COALESCE($39, vk_group_id),
vk_secret_key = COALESCE($40, vk_secret_key),
vk_confirmation_code = COALESCE($41, vk_confirmation_code),
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 = $46
WHERE id = 1`,
body.BusinessName, body.BusinessINN, body.BusinessKPP, body.BusinessAddress, body.BusinessPhone,
body.BusinessBankName, body.BusinessBankAccount, body.BusinessBankBIK, body.BusinessBankCorrAccount,
body.GeminiAPIKey, body.GeminiModel, body.TGBotToken, body.TGChatID, body.TGAPIBaseURL, body.IMEIAPIKey,
body.SMSProvider, body.SMSAPIID, body.SMSFrom, body.SMSEnabled,
body.ClientTGBotUsername, body.TGWebhookSecret, body.ClientNotifyEnabled, body.PublicTrackingURL,
body.LoyaltyEnabled, body.LoyaltyAccrualPercent, body.DefaultWarrantyDays,
body.KkmEnabled, body.KkmServerURL, body.KkmLogin, body.KkmPassword, body.KkmNumDevice, body.KkmTax,
body.DiscountApprovalEnabled, body.DiscountApprovalThresholdPercent,
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 {
log.Printf("settings: update failed: %v", err)
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
s, err := Fetch(context.Background(), h.db)
if err != nil {
log.Printf("settings: fetch after update failed: %v", err)
return c.Status(500).JSON(fiber.Map{"error": "internal error"})
}
return c.JSON(s)
}