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).
56 lines
1.8 KiB
Go
56 lines
1.8 KiB
Go
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
|
|
}
|