Files
aura-crm/production/backend/internal/license/middleware.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

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(),
})
}
}