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).
85 lines
3.0 KiB
Go
85 lines
3.0 KiB
Go
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(),
|
|
})
|
|
}
|
|
}
|