56ffca767d
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).
325 lines
15 KiB
Go
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)
|
|
}
|